From 6622d12f1ccffb4c420dd465208caff96181134a Mon Sep 17 00:00:00 2001 From: Shawn Patel Date: Fri, 18 Sep 2026 17:05:33 -0700 Subject: [PATCH 01/80] Stiction compensation, 0xA4 wire mode, and firmware-loop tuning for the X8 shoulders MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Slow teleop moves on the right arm stair-stepped at ~2 Hz. Traces and a new static-friction probe pin it on the RMD-X8-P20 shoulder gear trains: breakaway 2.4-3.3 Nm under 10-15 Nm of gravity load against 0.66 Nm at rest, sliding friction falling from 2.1 Nm at 0.05 rad/s to 0.7 Nm at 0.2 rad/s (velocity- weakening, ~-7 Nm·s/rad), so a slow slide is unstable under any feedforward. Controller side (all opt-in, production law unchanged at the defaults): - JointConfig stiction_gain / stiction_load_gain / stiction_err_deg: an error-sign Coulomb push, sized gain·fc + load_gain·|gravity|, fading on measured velocity. Cut right shoulder_1's stairs 4x (0.58° -> 0.14°) and halved shoulder_2's error on the slow_osc replay; safe at rest. - dither_nm / dither_hz: sinusoidal torque dither, golden-angle staggered per joint. 2 Nm took 40 % off the 1-4 Hz band, did not remove the cycle. - wire_mode a4: the core commands a MyActuator joint with 0xA4 (firmware position loop, speed-capped) plus a paired 0x92 read for 0.01° position; measured torque reads NaN on such joints. Stock firmware gains gave 600 ms lag and the same 2 Hz velocity cycle; 3x speed_kp vibrated. - Config protocol 2 -> 6 (stiction, load gain, dither, wire token); trace gains stiction_ff and dither_ff columns; Rust math golden-pinned to Python. Tools: - axol tune.breakaway: static-friction probe (kp = 0 hold, trimmed gravity feedforward, escalating torque ramps, both directions). - axol tune.a4: sine / constant-speed triangle over 0xA4 with per-run firmware gains (RAM unless --persist, restored after), planner acceleration, a buzz guard, and creep-smoothness metrics; registered in the serve catalog with a "Firmware loop" workbench tab. - tune.motion --arms and --a4, and stiction/dither fields in --gain. - scripts/creep_test.py and scripts/fw_gains.py for standalone probing. - tuning/motions/slow_osc.npz: the recorded slow motion every A/B replayed. Web lint/tsc not run here (no Node on the robot); CI covers it. Co-Authored-By: Claude Fable 5.1 --- README.md | 2 + almond_axol/cli/__init__.py | 5 +- almond_axol/cli/tune/a4.py | 650 ++++++++++++++++++ almond_axol/cli/tune/breakaway.py | 645 +++++++++++++++++ almond_axol/cli/tune/motion.py | 41 +- almond_axol/robot/axol.py | 38 +- almond_axol/robot/config.py | 71 +- almond_axol/robot/control.py | 123 +++- almond_axol/rt/link.py | 2 +- almond_axol/rt/robot.py | 33 +- almond_axol/serve/commands.py | 18 + almond_axol/teleop/recorder.py | 2 + almond_axol/tuning/motions/slow_osc.npz | Bin 0 -> 324059 bytes docs/cli/tune-a4.mdx | 48 ++ docs/cli/tune-breakaway.mdx | 35 + docs/cli/tune-motion.mdx | 4 +- docs/docs.json | 2 + docs/snippets/config/robot.mdx | 6 + rust/axol-rt/README.md | 2 +- rust/axol-rt/src/bringup.rs | 56 ++ rust/axol-rt/src/filter.rs | 119 +++- rust/axol-rt/src/hold.rs | 8 +- rust/axol-rt/src/proto.rs | 51 ++ rust/axol-rt/src/serve.rs | 389 ++++++++--- scripts/creep_test.py | 378 ++++++++++ scripts/fw_gains.py | 103 +++ tests/test_command_sections.py | 1 + tests/test_rom_partial_arm.py | 13 +- tests/test_rt_link.py | 7 +- tests/test_rt_mantis.py | 4 +- tests/test_stiction.py | 156 +++++ tests/test_tune_a4.py | 138 ++++ tests/test_tune_breakaway.py | 99 +++ .../diagnostics/tuning-workbench.tsx | 59 ++ 34 files changed, 3195 insertions(+), 113 deletions(-) create mode 100644 almond_axol/cli/tune/a4.py create mode 100644 almond_axol/cli/tune/breakaway.py create mode 100644 almond_axol/tuning/motions/slow_osc.npz create mode 100644 docs/cli/tune-a4.mdx create mode 100644 docs/cli/tune-breakaway.mdx create mode 100644 scripts/creep_test.py create mode 100644 scripts/fw_gains.py create mode 100644 tests/test_stiction.py create mode 100644 tests/test_tune_a4.py create mode 100644 tests/test_tune_breakaway.py diff --git a/README.md b/README.md index 9d7aa369..e7468283 100644 --- a/README.md +++ b/README.md @@ -191,6 +191,8 @@ Each operation can be driven from the web control panel or the CLI: - [`tracker.*`](https://docs.almond.bot/cli/tracker) — Mantis tracker setup: bridge, identify, pair, install, and base-station / Ultimate checks - [`tune.pid`](https://docs.almond.bot/cli/tune-pid) - [`tune.friction`](https://docs.almond.bot/cli/tune-friction) +- [`tune.breakaway`](https://docs.almond.bot/cli/tune-breakaway) +- [`tune.a4`](https://docs.almond.bot/cli/tune-a4) - [`tune.gravity`](https://docs.almond.bot/cli/tune-gravity) - [`tune.factory`](https://docs.almond.bot/cli/tune-factory) - [`calibration.pull`](https://docs.almond.bot/cli/tune-factory#calibration-pull) diff --git a/almond_axol/cli/__init__.py b/almond_axol/cli/__init__.py index b53cfdc2..a9adb898 100644 --- a/almond_axol/cli/__init__.py +++ b/almond_axol/cli/__init__.py @@ -38,7 +38,8 @@ from .motor import set_config as motor_set_config from .tune import factory as tune_factory from .tune import filter as tune_filter -from .tune import friction, pid, repeatability +from .tune import a4 as tune_a4 +from .tune import breakaway, friction, pid, repeatability from .tune import gravity as tune_gravity from .tune import motion as tune_motion from .zed import driver as zed_driver @@ -165,6 +166,8 @@ def build_parser() -> argparse.ArgumentParser: jetson_setup.add_parser(subparsers) pid.add_parser(subparsers) friction.add_parser(subparsers) + breakaway.add_parser(subparsers) + tune_a4.add_parser(subparsers) tune_gravity.add_parser(subparsers) tune_factory.add_parser(subparsers) calibration_cmd.add_parser(subparsers) diff --git a/almond_axol/cli/tune/a4.py b/almond_axol/cli/tune/a4.py new file mode 100644 index 00000000..bb2ff0e4 --- /dev/null +++ b/almond_axol/cli/tune/a4.py @@ -0,0 +1,650 @@ +""" +axol tune.a4 + +Tune a MyActuator joint's **firmware position loop** (0xA4 absolute position +closed-loop) — the loop the realtime core drives when a joint's +``wire_mode`` is ``a4``. Streams a sine or constant-speed triangle target at +``--rate`` Hz with the firmware gains, speed cap and planner acceleration you +choose, reads the fine 0.01° position (0x92) every cycle, scores tracking and +smoothness, and saves the run for the diagnostics dashboard. + +Why a separate tool: ``tune.pid`` tunes the MIT impedance frame, whose gains +live in the host. Under 0xA4 the whole controller is the motor's own +position PI → speed PI → current loop, so the knobs are the firmware gains +(``position_kp/ki/kd``, ``speed_kp/ki``, ``current_kp/ki``), the 0xA4 speed +cap, and the position planner's acceleration (0 = direct PI tracking of the +stream; anything else re-plans every target and will not follow a stream). + +Safety: + +* Gains are written to **RAM** (0x31) unless ``--persist`` is given, and the + pre-run values are written back when the run ends (``--keep`` skips that + so a winner stays). The tuning flows reset a motor when they switch its + control mode, which also reloads ROM gains — so the gains are written + *after* the mode switch and homing, right before the wave. +* A buzz guard watches the fine position for high-frequency motion and the + reply current; past ``--buzz-abort`` degrees of >10 Hz content or + ``--iq-abort`` amps it restores the previous gains at once, holds, and + ends the run. Shoulder_1 at speed_kp 0.1 (3× stock) vibrated immediately + on 2026-09-18; start every sweep from the stock values in small steps. +* The joint under test holds position stiffly in this mode and will push + back against contact up to motor torque. Keep the workspace clear. + +Examples: + axol tune.a4 --r --joint shoulder_1 --center -35 --amp 10 --mode triangle --speed 3 + axol tune.a4 --r --joint shoulder_1 --mode sine --freq 0.3 --speed-kp 0.05 --speed-ki 0.0005 + axol tune.a4 --r --joint shoulder_1 --accel 0 --position-kp 0.02 --save-run --label "pkp 0.02" +""" + +from __future__ import annotations + +import argparse +import asyncio +import math +import struct +import time +from collections import deque +from typing import Any + +import numpy as np + +from ...constants import ARM_JOINTS, Joint +from ...motor import CanBus, ControlMode, Motor +from ...motor.myactuator import _MA_PID_IDX, MyActuatorMotor +from ...tuning import ( + JointFrameMotor, + joint_frame_motors, + log_to_series, + ramp_stages, + safe_limits, + save_run, + sine_metrics, + sweep_safety, +) +from ...tuning.runner import LiveStream, report_achieved_rate +from ..motor import add_side_and_channel_arguments, resolve_channel +from .friction import _home_all, _ramp_verified + +_MA_POS_CONTROL = 0xA4 +_MA_MULTI_TURN_ANGLE = 0x92 +_MA_READ_ACCEL = 0x42 +_MA_WRITE_ACCEL = 0x43 +_MA_READ_GAIN = 0x30 +_MA_WRITE_GAIN_RAM = 0x31 +_MA_WRITE_GAIN_ROM = 0x32 +_FLASH_SETTLE_S = 0.3 + +GAIN_NAMES: tuple[str, ...] = tuple(_MA_PID_IDX) + +#: Position error (rad) past which the wave is abandoned — the loop is not +#: following at all (planner in profiled mode, or a runaway). +_ERR_ABORT = math.radians(20.0) +#: Window (s) the buzz guard evaluates high-frequency motion over. +_BUZZ_WINDOW_S = 0.1 + + +# --------------------------------------------------------------------------- +# Pure pieces (unit-tested) +# --------------------------------------------------------------------------- + + +def waveform( + mode: str, + center: float, + amp: float, + duration: float, + rate: float, + *, + freq: float = 0.5, + speed: float = 0.05, +) -> list[tuple[float, float, float]]: + """``(t, target, v_cmd)`` samples of a sine (``freq`` Hz) or a constant-speed + triangle (``speed`` rad/s, ``amp`` half-travel) about ``center`` (rad). + + Both start at ``center`` with zero velocity so the first command is the + hold pose the joint was ramped to. The triangle is the stick-slip probe: + the whole pass runs at one speed, so creep behaviour is not confined to + the sine's turnarounds. + """ + n = max(1, math.ceil(duration * rate)) + out: list[tuple[float, float, float]] = [] + if mode == "sine": + w = 2.0 * math.pi * freed(freq) + for k in range(n): + t = k / rate + out.append((t, center + amp * math.sin(w * t), amp * w * math.cos(w * t))) + return out + if mode != "triangle": + raise ValueError(f"unknown mode {mode!r}") + v = abs(speed) + if v <= 0.0 or amp <= 0.0: + return [(k / rate, center, 0.0) for k in range(n)] + # Triangle: centre → +amp → −amp → +amp …, each leg at constant speed. + for k in range(n): + t = k / rate + s = v * t # distance travelled along the zig-zag + # Fold onto a 4·amp period: 0..amp up, amp..3amp down, 3amp..4amp up. + phase = math.fmod(s, 4.0 * amp) + if phase < amp: + q, vs = center + phase, +v + elif phase < 3.0 * amp: + q, vs = center + amp - (phase - amp), -v + else: + q, vs = center - amp + (phase - 3.0 * amp), +v + out.append((t, q, vs)) + return out + + +def freed(freq: float) -> float: + """Positive frequency (a zero or negative request becomes a hold).""" + return max(freq, 0.0) + + +class BuzzGuard: + """Abort detector: high-frequency position motion or excess current. + + Feed one ``(position rad, iq A)`` sample per cycle. High-frequency motion + is the RMS of the position about its mean over the last + :data:`_BUZZ_WINDOW_S`; a commanded wave contributes little to that at + creep speeds, a limit cycle or resonance a lot. + """ + + def __init__(self, rate: float, buzz_rad: float, iq_abort: float) -> None: + self._n = max(4, int(round(_BUZZ_WINDOW_S * rate))) + self._pos: deque[float] = deque(maxlen=self._n) + self.buzz_rad = buzz_rad + self.iq_abort = iq_abort + self.peak_buzz = 0.0 + self.peak_iq = 0.0 + + def feed(self, pos: float, iq: float) -> str | None: + self._pos.append(pos) + self.peak_iq = max(self.peak_iq, abs(iq)) + if self.iq_abort > 0.0 and abs(iq) > self.iq_abort: + return f"current {iq:+.2f} A past the {self.iq_abort:g} A limit" + if len(self._pos) < self._n: + return None + arr = np.asarray(self._pos) + # Remove the wave itself (a straight line over 0.1 s) before scoring. + x = np.arange(self._n) + coef = np.polyfit(x, arr, 1) + resid = arr - np.polyval(coef, x) + buzz = float(np.sqrt(np.mean(resid * resid))) + self.peak_buzz = max(self.peak_buzz, buzz) + if self.buzz_rad > 0.0 and buzz > self.buzz_rad: + return f"{math.degrees(buzz):.2f}° of high-frequency motion (limit {math.degrees(self.buzz_rad):.2f}°)" + return None + + +def a4_metrics(log: list[dict], rate: float) -> dict[str, Any]: + """Score a firmware-loop run: the sine scorecard plus creep smoothness.""" + m: dict[str, Any] = sine_metrics(log) + if len(log) < 20: + return m + t = np.array([r["t"] for r in log]) + target = np.array([r["target"] for r in log]) + actual = np.array([r["actual"] for r in log]) + v_cmd = np.array([r["v_cmd"] for r in log]) + iq = np.array([r["iq"] for r in log]) + dt = float(np.median(np.diff(t))) if len(t) > 1 else 1.0 / rate + err = actual - target + # Lag: how far behind the command the joint runs, from the error while + # moving (error against the direction of travel over the speed). Works + # for a ramp or triangle, where a correlation shift would be swallowed + # by an offset, and for a sine, where the sign flips each half-cycle. + moving_now = np.abs(v_cmd) > 0.02 + if moving_now.any(): + behind = float(np.mean(-err[moving_now] * np.sign(v_cmd[moving_now]))) + m["lag_ms"] = behind / float(np.mean(np.abs(v_cmd[moving_now]))) * 1000.0 + else: + m["lag_ms"] = math.nan + # 1-4 Hz band of the error (the stick-slip band). + e = err - err.mean() + n = len(e) + F = np.abs(np.fft.rfft(e * np.hanning(n))) ** 2 + f = np.fft.rfftfreq(n, dt) + tot = float(F.sum()) + m["band_1_4"] = ( + float(math.sqrt(F[(f >= 1) & (f <= 4)].sum() / tot) * e.std()) + if tot > 0 + else 0.0 + ) + # Velocity ripple relative to the command, and stuck windows, while the + # command is actually moving. + win = max(2, int(round(0.05 / dt))) + kern = np.ones(win) / win + v_meas = np.convolve(np.gradient(actual, t), kern, mode="same") + moving = np.abs(v_cmd) > 0.02 + v_ref = float(np.mean(np.abs(v_cmd[moving]))) if moving.any() else 0.0 + m["v_ripple"] = ( + float(np.std((v_meas - v_cmd)[moving]) / v_ref) if v_ref > 0 else math.nan + ) + nwin = len(actual) // win + if nwin and moving.any(): + blocks = actual[: nwin * win].reshape(nwin, win) + mv_blocks = moving[: nwin * win].reshape(nwin, win).all(axis=1) + travel = np.abs(blocks[:, -1] - blocks[:, 0]) + m["stuck_frac"] = ( + float(np.mean(travel[mv_blocks] < math.radians(0.03))) + if mv_blocks.any() + else math.nan + ) + else: + m["stuck_frac"] = math.nan + # >10 Hz position content: buzz/resonance. + m["buzz"] = float(math.sqrt(F[f >= 10].sum() / tot) * e.std()) if tot > 0 else 0.0 + m["iq_rms"] = float(np.sqrt(np.nanmean(iq * iq))) + m["iq_max"] = float(np.nanmax(np.abs(iq))) + return m + + +# --------------------------------------------------------------------------- +# Motor access +# --------------------------------------------------------------------------- + + +def _a4_frame(position_rad: float, cap_dps: float) -> bytes: + cap = int(max(0.0, min(65535.0, round(cap_dps)))) + return ( + bytes([_MA_POS_CONTROL, 0x00]) + + struct.pack(" tuple[float, float]: + """(iq A, speed rad/s) from a 0xA4 reply.""" + iq = struct.unpack_from(" dict[str, float]: + out: dict[str, float] = {} + for name, index in _MA_PID_IDX.items(): + resp = await driver._request(bytes([_MA_READ_GAIN, index, 0, 0, 0, 0, 0, 0])) + out[name] = float(struct.unpack_from(" None: + cmd = _MA_WRITE_GAIN_ROM if persist else _MA_WRITE_GAIN_RAM + for name, value in gains.items(): + await driver._request( + bytes([cmd, _MA_PID_IDX[name], 0, 0]) + struct.pack(" tuple[int, int]: + out = [] + for kind in (0x00, 0x01): + resp = await driver._request(bytes([_MA_READ_ACCEL, kind, 0, 0, 0, 0, 0, 0])) + out.append(int(struct.unpack_from(" tuple[int, int]: + for kind, value in ((0x00, acc), (0x01, dec)): + await driver._request( + bytes([_MA_WRITE_ACCEL, kind, 0, 0]) + struct.pack(" tuple[list[dict], str | None]: + """Stream the wave; returns the log and the abort reason, if any.""" + offset = motor.offset + period = 1.0 / rate + log: list[dict] = [] + t0 = time.perf_counter() + deadline = t0 + for _t_nominal, target, v_cmd in samples: + deadline += period + resp = await driver._request(_a4_frame(target - offset, cap_dps)) + iq, speed = _decode_a4_reply(resp) + fine = await driver._request(bytes([_MA_MULTI_TURN_ANGLE, 0, 0, 0, 0, 0, 0, 0])) + pos = struct.unpack_from(" _ERR_ABORT: + reason = f"tracking error {math.degrees(pos - target):+.1f}° — the loop is not following" + if reason is not None: + return log, reason + await asyncio.sleep(max(0.0, deadline - time.perf_counter())) + return log, None + + +async def _hold( + driver: MyActuatorMotor, + motor: JointFrameMotor, + pose: float, + cap_dps: float, + seconds: float, +) -> None: + period = 0.01 + end = time.perf_counter() + seconds + while time.perf_counter() < end: + await driver._request(_a4_frame(pose - motor.offset, cap_dps)) + await asyncio.sleep(period) + + +# --------------------------------------------------------------------------- +# CLI +# --------------------------------------------------------------------------- + + +def add_parser(subparsers: argparse._SubParsersAction) -> None: # type: ignore[type-arg] + """Register the ``tune.a4`` subcommand.""" + p = subparsers.add_parser( + "tune.a4", + help="Tune a MyActuator joint's firmware position loop (0xA4) with a sine or triangle.", + formatter_class=argparse.RawDescriptionHelpFormatter, + description=__doc__, + ) + add_side_and_channel_arguments(p) + p.add_argument( + "--joint", + required=True, + choices=[j.value for j in ARM_JOINTS], + help="Joint to drive (MyActuator joints: shoulder_1 … wrist_1)", + ) + p.add_argument( + "--mode", + choices=["sine", "triangle"], + default="triangle", + help="Wave shape: sine (--freq) or constant-speed triangle (--speed) (default: triangle)", + ) + p.add_argument( + "--center", + type=float, + default=None, + help="Centre, joint-frame degrees (default: joint midpoint of the safe range)", + ) + p.add_argument( + "--amp", type=float, default=10.0, help="Half-travel, degrees (default: 10)" + ) + p.add_argument( + "--freq", type=float, default=0.3, help="[sine] frequency, Hz (default: 0.3)" + ) + p.add_argument( + "--speed", + type=float, + default=3.0, + help="[triangle] pass speed, deg/s (default: 3)", + ) + p.add_argument( + "--duration", type=float, default=12.0, help="Seconds of wave (default: 12)" + ) + p.add_argument( + "--rate", type=float, default=200.0, help="Command rate, Hz (default: 200)" + ) + p.add_argument( + "--cap", type=float, default=60.0, help="0xA4 speed cap, deg/s (default: 60)" + ) + p.add_argument( + "--accel", + type=int, + default=None, + help="Position-planner acceleration (dps/s) for the run: 0 = direct PI tracking of the " + "stream (required for it to follow at all); default: leave the stored value. Written to " + "ROM and restored afterwards unless --keep", + ) + for name in GAIN_NAMES: + p.add_argument( + f"--{name.replace('_', '-')}", + dest=name, + type=float, + default=None, + help=f"Firmware {name} for this run (default: leave as is)", + ) + p.add_argument( + "--persist", + action="store_true", + help="Write gains to ROM (0x32) instead of RAM (0x31)", + ) + p.add_argument( + "--keep", + action="store_true", + help="Leave the run's gains and planner acceleration in the motor afterwards", + ) + p.add_argument( + "--buzz-abort", + type=float, + default=0.3, + help="Abort past this much >10 Hz position motion, degrees RMS over 0.1 s (default: 0.3; 0 off)", + ) + p.add_argument( + "--iq-abort", + type=float, + default=10.0, + help="Abort past this reply current, amps (default: 10; 0 off)", + ) + p.add_argument( + "--save-run", + action="store_true", + help="Persist the run for the diagnostics dashboard", + ) + p.add_argument("--label", default=None, help="Free-form note stored on the run") + p.add_argument( + "--group", default=None, help="Shared id linking the runs of one sweep" + ) + p.set_defaults(func=run) + + +def run(args: argparse.Namespace) -> None: + asyncio.run(_run(args)) + + +async def _run(args: argparse.Namespace) -> None: + joint = Joint(args.joint) + is_left = args.l + side = "left" if is_left else "right" + lo, hi = safe_limits(joint, is_left) + amp = math.radians(args.amp) + center = math.radians(args.center) if args.center is not None else (lo + hi) / 2.0 + margin = math.radians(2.0) + if not (lo + margin <= center - amp and center + amp <= hi - margin): + raise SystemExit( + f"{joint.value}: {math.degrees(center - amp):+.1f}..{math.degrees(center + amp):+.1f}° " + f"is outside the safe range [{math.degrees(lo) + 2:.1f}, {math.degrees(hi) - 2:.1f}]°" + ) + requested = { + n: getattr(args, n) for n in GAIN_NAMES if getattr(args, n) is not None + } + samples = waveform( + args.mode, + center, + amp, + args.duration, + args.rate, + freq=args.freq, + speed=math.radians(args.speed), + ) + print(f"\ntune.a4 — {side} {joint.value}: firmware position loop (0xA4)") + print( + f" {args.mode} about {math.degrees(center):+.1f}° ±{args.amp:g}°, " + + (f"{args.freq:g} Hz" if args.mode == "sine" else f"{args.speed:g} deg/s") + + f", {args.duration:g} s at {args.rate:g} Hz, speed cap {args.cap:g} dps" + ) + + channel = resolve_channel(args) + async with CanBus(channel) as bus: + raw = {j: Motor(bus, j) for j in ARM_JOINTS} + await asyncio.gather(*[m.enable() for m in raw.values()]) + motors = await joint_frame_motors(raw, is_left) + await asyncio.gather( + *[ + m.set_control_mode(ControlMode.POSITION_VELOCITY) + for m in motors.values() + ] + ) + motor = motors[joint] + driver = motor.motor._driver + if not isinstance(driver, MyActuatorMotor): + raise SystemExit(f"{joint.value} is not a MyActuator joint") + before_gains: dict[str, float] | None = None + before_accel: tuple[int, int] | None = None + log: list[dict] = [] + reason: str | None = None + used_gains: dict[str, float] = {} + accel_used: tuple[int, int] | None = None + try: + print(" Homing all joints to rest ...") + await _home_all(motors) + other_targets, _lo, _hi, notes = sweep_safety(joint, is_left) + for note in notes: + print(f" {note}") + for stage in ramp_stages(other_targets): + await _ramp_verified(motors, stage) + print(f" Ramping {joint.value} to {math.degrees(center):+.1f}° ...") + await _ramp_verified(motors, {joint: center}) + await asyncio.sleep(0.3) + + # Planner and gains: written after every mode switch/homing is + # done (those reset the motor and reload ROM). + stored_accel = await _read_accel(driver) + if args.accel is not None and stored_accel != (args.accel, args.accel): + before_accel = stored_accel + accel_used = await _write_accel(driver, args.accel, args.accel) + print( + f" planner accel/decel {stored_accel[0]}/{stored_accel[1]} → {accel_used[0]}/{accel_used[1]} dps/s" + ) + else: + accel_used = stored_accel + if accel_used[0] != 0: + print( + f" ! planner acceleration is {accel_used[0]} dps/s: the firmware re-plans " + "every streamed target and will not follow the wave — pass --accel 0" + ) + stock = await _read_gains(driver) + used_gains = {**stock, **requested} + if requested: + before_gains = stock + await _write_gains(driver, requested, args.persist) + used_gains = await _read_gains(driver) + for n in requested: + flag = ( + "" + if abs(used_gains[n] - requested[n]) + <= 1e-6 * max(1.0, abs(requested[n])) + else " (! not accepted)" + ) + print(f" {n:12s} {stock[n]:.6g} → {used_gains[n]:.6g}{flag}") + else: + print(" gains: " + ", ".join(f"{n}={v:.6g}" for n, v in stock.items())) + + guard = BuzzGuard(args.rate, math.radians(args.buzz_abort), args.iq_abort) + live = LiveStream("sine", joint) + print(" Running ...") + log, reason = await _stream( + motor, driver, samples, args.cap, args.rate, guard, live + ) + live.flush() + if reason is not None: + print(f"\n ! aborted: {reason}") + if before_gains is not None: + await _write_gains(driver, before_gains, args.persist) + print(" previous gains restored") + before_gains = None + here = motor.position + await _hold(driver, motor, here, args.cap, 0.5) + report_achieved_rate(log, args.rate) + except KeyboardInterrupt: + print("\n Interrupted.") + finally: + print(" Returning to rest and disabling ...") + try: + if before_gains is not None and not args.keep: + await _write_gains(driver, before_gains, args.persist) + print(" previous gains restored") + elif before_gains is not None: + print(" gains kept (--keep)") + if before_accel is not None and not args.keep: + got = await _write_accel(driver, before_accel[0], before_accel[1]) + print(f" planner accel/decel restored to {got[0]}/{got[1]} dps/s") + elif before_accel is not None: + print( + f" planner left at {accel_used[0]}/{accel_used[1]} dps/s (--keep) — a direct-tracking joint executes a stored target on wake" + ) + except Exception as exc: # noqa: BLE001 - report, then keep tearing down + print(f" ! restore failed: {exc}") + try: + await _ramp_verified(motors, {joint: 0.0}) + await _home_all(motors) + except Exception: # noqa: BLE001 - best-effort teardown + pass + await asyncio.gather( + *[m.set_control_mode(ControlMode.IMPEDANCE) for m in motors.values()] + ) + await asyncio.gather(*[m.disable() for m in raw.values()]) + + if len(log) < 20: + print("\nToo few samples to score.") + return + metrics = a4_metrics(log, args.rate) + metrics["aborted"] = reason is not None + print(f"\n{'─' * 66}") + print( + f" tracking RMS {math.degrees(metrics['rms']):.3f}° max {math.degrees(metrics['max']):.3f}° lag {metrics['lag_ms']:.0f} ms" + ) + print( + f" 1-4 Hz band {math.degrees(metrics['band_1_4']):.3f}° >10 Hz buzz {math.degrees(metrics['buzz']):.3f}°" + ) + print( + f" velocity ripple {metrics['v_ripple']:.2f} (MIT stick-slip ≈ 0.8, smooth < 0.2) stuck windows {metrics['stuck_frac']:.2f}" + ) + print( + f" current RMS {metrics['iq_rms']:.2f} A peak {metrics['iq_max']:.2f} A loop {metrics['hz']:.0f} Hz" + ) + print(f"{'─' * 66}") + if args.save_run: + params = { + "wire": "a4", + "mode": args.mode, + "center_deg": math.degrees(center), + "amp_deg": args.amp, + "freq_hz": args.freq if args.mode == "sine" else None, + "speed_dps": args.speed if args.mode == "triangle" else None, + "duration_s": args.duration, + "rate_hz": args.rate, + "cap_dps": args.cap, + "accel": list(accel_used) if accel_used else None, + "persist": args.persist, + } + run_id = save_run( + "sine", + log_to_series(log), + metrics, + side=side, + joint=joint.value, + gains=used_gains, + params=params, + label=args.label, + group=args.group, + ) + print(f"\nSaved tuning run {run_id} (kind=sine, wire=a4)") diff --git a/almond_axol/cli/tune/breakaway.py b/almond_axol/cli/tune/breakaway.py new file mode 100644 index 00000000..aadba5ce --- /dev/null +++ b/almond_axol/cli/tune/breakaway.py @@ -0,0 +1,645 @@ +""" +axol tune.breakaway + +Measure a joint's **breakaway** (static) torque — the torque at which a +stationary joint first moves — and compare it to the sliding Coulomb torque +``fc`` the friction model already knows. + +Why this exists, and why ``tune.friction`` cannot answer it: + +* ``tune.friction`` sweeps at fixed velocities and fits + ``fc·tanh(0.1·k·v) + fv·v``. That model is monotonic in ``v`` and has no + static/kinetic distinction — it cannot represent breakaway at any + parameter value. +* Its sweep bottoms out around 0.13 rad/s, and one LSB of reported motor + velocity is ~0.022 rad/s. Creep at a few thousandths of a rad/s is below + the velocity channel entirely, so a "slower sweep" fits torque against a + signal that reads zero. + +Stick-slip exists precisely *because* breakaway exceeds sliding friction: a +joint tracking a slow target sticks until ``kp·err`` makes up the gap, lets +go, overshoots, and re-sticks — the 2 Hz, ~0.5° stairs on the X8-P20 +shoulders. That gap is what this measures, and it needs no velocity signal at +all — only the torque at the instant position moves. + +Method, per pose: + +1. Home the arm, apply the shared sweep-safety clearances, and ramp the test + joint to the pose under impedance control. +2. Drop to ``kp = 0`` — no position spring — with a small ``kd`` so the joint + cannot run away once it releases. Gravity feedforward is then the only + thing holding it. +3. Trim that feedforward until the joint stands still. A joint sits still for + any trim inside its stiction band, so this converges quickly; without it + loaded poses are unmeasurable (at 15 Nm a 2 % gravity-model error + outweighs the whole breakaway torque). +4. Ramp an extra torque up and back down in a triangle and watch for the + first motion past ``--move-deg``. Peaks escalate over attempts and the + search stops at the first release, so the joint never sees more torque + than it took to move it; the instant it moves it is caught under ``kp`` + and returned to the pose. +5. Repeat in both directions, ``--trials`` times each. + +Probing both directions separates two things one average hides — the +symmetric half is friction, the antisymmetric half is whatever standing +torque the trimmed feedforward still missed: + +* **F_static** and ``F_static / fc``. The excess over ``fc`` is the torque a + pure velocity feedforward can never supply while the joint is stuck, and + ``(F_static − fc) / kp`` is the predicted stick-slip stair height. If it + matches the stairs a slow ``tune.motion`` replay shows, the diagnosis is + closed. +* ``stiction_gain`` (see ``JointConfig``) should stay **below** + ``F_static / fc − 1`` … the compensation must not exceed the friction it + is compensating, or the joint hunts around the target at rest. + +Examples: + axol tune.breakaway --r --joint shoulder_1 + axol tune.breakaway --r --joint shoulder_2 --trials 5 --csv ~/breakaway-s2.csv + axol tune.breakaway --r --joint shoulder_1 --poses -30 0 30 # load dependence +""" + +from __future__ import annotations + +import argparse +import asyncio +import csv +import math +import time +from dataclasses import dataclass, field +from pathlib import Path + +import numpy as np + +from ...constants import ARM_JOINTS, Joint +from ...motor import CanBus, ControlMode, Motor, MotorError +from ...robot.config import ArmConfig, AxolConfig +from ...robot.gravity import GravityCompensator +from ...tuning import ( + JointFrameMotor, + joint_frame_motors, + ramp_impedance, + ramp_stages, + safe_limits, + safe_outboard_direction, + sweep_safety, +) +from ..motor import add_side_and_channel_arguments, resolve_channel +from .friction import _home_all, _ramp_verified + +_RATE_HZ = 100.0 +#: Drift (rad) over one trim hold below which the joint counts as standing +#: still: ~1.4 LSB of the 16-bit MIT position, i.e. the noise floor. +_HOLD_TOL = math.radians(0.03) +#: Duration of one kp = 0 trim hold, and the settle time skipped at its start. +_TRIM_HOLD_S = 1.0 +_TRIM_SETTLE_S = 0.25 +#: Displacement (rad) from the pose at which a kp = 0 phase is abandoned and +#: the joint is caught under kp — the "it let go" guard for the trim search +#: and the backstop for a release the ramp somehow missed. +_CATCH = math.radians(3.0) +#: Joints with a base-collision boundary at 0 are probed at least this far +#: outboard so a release toward the boundary cannot cross it. +_BOUNDARY_MARGIN = math.radians(5.0) +#: Floor on the friction level the escalation schedule is scaled by, so the +#: low-friction Damiao wrists (fc ≈ 0.1 Nm) still get a usable ramp. +_FC_FLOOR_NM = 0.2 +#: Escalating ramp peaks as multiples of the (floored) fc, cut at --max-torque. +_PEAK_MULTIPLES = (0.5, 0.75, 1.0, 1.25, 1.5, 2.0, 2.5, 3.0, 3.5, 4.0) + + +def peak_schedule(fc: float, max_multiple: float) -> list[float]: + """Ramp peaks (Nm) to try in order: ``_PEAK_MULTIPLES × max(fc, floor)``, + keeping every peak at or below ``max_multiple × max(fc, floor)``.""" + ref = max(fc, _FC_FLOOR_NM) + return [m * ref for m in _PEAK_MULTIPLES if m <= max_multiple + 1e-9] + + +def triangle(t: float, ramp_s: float) -> float: + """Unit triangle: 0 → 1 at ``ramp_s / 2`` → 0 at ``ramp_s``; 0 outside.""" + if t <= 0.0 or t >= ramp_s: + return 0.0 + half = ramp_s / 2.0 + return t / half if t <= half else (ramp_s - t) / half + + +def split_breakaway(plus: list[float], minus: list[float]) -> tuple[float, float]: + """``(F_static, bias)`` from the released torques in each direction. + + With the joint held by ``g_model + trim`` and the true standing torque + ``g_true``, the residual ``r = g_model + trim − g_true`` helps one + direction and hinders the other: ``b+ = F_static − r``, + ``b− = F_static + r``. So the symmetric half is friction and the + antisymmetric half is the residual the trim search left inside the + stiction band (``r`` is what the feedforward *over*-supplies in the + + direction). + """ + bp = float(np.mean(plus)) + bm = float(np.mean(minus)) + return (bp + bm) / 2.0, (bm - bp) / 2.0 + + +def predicted_stair_deg(f_static: float, fc: float, kp: float) -> float: + """Stick-slip stair height (deg) a pure velocity feedforward leaves: + the joint sticks until ``kp·err`` covers what ``fc`` did not.""" + if kp <= 0.0: + return math.nan + return math.degrees(max(f_static - fc, 0.0) / kp) + + +@dataclass +class TrimSearch: + """Bisection-style search for the feedforward trim that holds the joint. + + Feed one drift measurement (rad over a hold) per step; :attr:`trim` is the + correction to apply next. The step shrinks whenever the drift changes + sign, so the search closes on an edge of the stiction band. ``done`` is + set on a hold that stood still; ``failed`` when the trim runs away + (gravity model far off, or the joint is not actually free). + """ + + step: float + max_trim: float + trim: float = 0.0 + done: bool = False + failed: bool = False + _last_sign: int = 0 + steps: int = field(default=0) + + def update(self, drift: float) -> float: + if abs(drift) < _HOLD_TOL: + self.done = True + return self.trim + sign = 1 if drift > 0 else -1 + if self._last_sign and sign != self._last_sign: + self.step /= 2.0 + self._last_sign = sign + # Drifting + means the applied torque exceeds the standing load. + self.trim -= sign * self.step + self.steps += 1 + if abs(self.trim) > self.max_trim or self.steps > 16: + self.failed = True + return self.trim + + +async def _hold( + motor: JointFrameMotor, + pose: float, + kp: float, + kd: float, + t_ff: float, + duration: float, +) -> None: + """Command ``(pose, kp, kd, t_ff)`` at the probe rate for ``duration``.""" + period = 1.0 / _RATE_HZ + deadline = time.perf_counter() + end = deadline + duration + while deadline < end: + deadline += period + await motor.set_impedance(pose, 0.0, kp, kd, t_ff) + await asyncio.sleep(max(0.0, deadline - time.perf_counter())) + + +async def _catch( + motor: JointFrameMotor, + pose: float, + kp: float, + kd: float, + gravity_fn, +) -> None: + """Take a released joint back under ``kp`` and return it to ``pose``.""" + here = motor.position + await _hold(motor, here, kp, kd, gravity_fn(here), 0.4) + await ramp_impedance(motor, kp, kd, pose, gravity_fn, rate_hz=_RATE_HZ) + await _hold(motor, pose, kp, kd, gravity_fn(pose), 0.5) + + +async def _trim( + motor: JointFrameMotor, + pose: float, + kp: float, + kd: float, + kd_probe: float, + gravity_fn, + fc_ref: float, + trim0: float = 0.0, +) -> float | None: + """Find the feedforward trim (Nm) that holds the joint still at kp = 0.""" + search = TrimSearch(step=0.15 * fc_ref, max_trim=3.0 * fc_ref, trim=trim0) + g = gravity_fn(pose) + while True: + period = 1.0 / _RATE_HZ + deadline = time.perf_counter() + t0 = deadline + p_start: float | None = None + caught = False + while time.perf_counter() - t0 < _TRIM_HOLD_S: + deadline += period + await motor.set_impedance(pose, 0.0, 0.0, kd_probe, g + search.trim) + pos = motor.position + if abs(pos - pose) > _CATCH: + caught = True + break + if p_start is None and time.perf_counter() - t0 >= _TRIM_SETTLE_S: + p_start = pos + await asyncio.sleep(max(0.0, deadline - time.perf_counter())) + if caught: + drift = motor.position - pose + await _catch(motor, pose, kp, kd, gravity_fn) + else: + drift = motor.position - (p_start if p_start is not None else pose) + trim = search.update(drift) + print( + f" trim {trim:+.3f} Nm drift {math.degrees(drift):+.3f}°" + f"{' (let go — caught)' if caught else ''}" + ) + if search.done: + return trim + if search.failed: + return None + + +async def _release( + motor: JointFrameMotor, + pose: float, + direction: int, + peak: float, + ramp_s: float, + kd_probe: float, + hold_ff: float, + move_thr: float, + writer: csv.writer | None, + tag: tuple, +) -> float | None: + """One triangle ramp of ``direction × peak``; the extra torque at first + motion past ``move_thr``, or ``None`` if the joint never moved.""" + period = 1.0 / _RATE_HZ + t0 = time.perf_counter() + deadline = t0 + pos0 = motor.position + while True: + t = time.perf_counter() - t0 + if t >= ramp_s: + return None + deadline += period + extra = direction * peak * triangle(t, ramp_s) + await motor.set_impedance(pose, 0.0, 0.0, kd_probe, hold_ff + extra) + pos = motor.position + moved = pos - pos0 + try: + tau = motor.torque + except MotorError: + tau = math.nan + if writer is not None: + writer.writerow( + [ + *tag, + f"{t:.4f}", + f"{extra:.5f}", + f"{math.degrees(pos):.5f}", + f"{tau:.4f}", + ] + ) + if abs(moved) > move_thr: + return abs(extra) + await asyncio.sleep(max(0.0, deadline - time.perf_counter())) + + +def add_parser(subparsers: argparse._SubParsersAction) -> None: # type: ignore[type-arg] + """Register the ``tune.breakaway`` subcommand.""" + p = subparsers.add_parser( + "tune.breakaway", + help="Measure a joint's static (breakaway) friction against its sliding fc.", + formatter_class=argparse.RawDescriptionHelpFormatter, + description=__doc__, + ) + add_side_and_channel_arguments(p) + p.add_argument( + "--joint", + required=True, + choices=[j.value for j in ARM_JOINTS], + help="Joint to probe", + ) + p.add_argument( + "--poses", + type=float, + nargs="+", + default=None, + metavar="DEG", + help="Joint-frame poses (degrees, 0 = rest) to probe at (default: 0; " + "base-collision joints are pushed 5° outboard of their boundary)", + ) + p.add_argument( + "--trials", type=int, default=3, help="Releases per direction (default: 3)" + ) + p.add_argument( + "--ramp-s", + type=float, + default=4.0, + help="Seconds for one up-and-back torque ramp (default: 4)", + ) + p.add_argument( + "--max-torque", + type=float, + default=3.0, + help="Largest ramp peak to try, as a multiple of the joint's fc " + "(floored at 0.2 Nm; default: 3.0)", + ) + p.add_argument( + "--move-deg", + type=float, + default=0.06, + help="Motion (degrees) that counts as a release — about 3 LSB of the " + "16-bit MIT position (default: 0.06)", + ) + p.add_argument( + "--kd", + type=float, + default=1.0, + help="Firmware damping during the kp = 0 phases, so a released joint " + "creeps rather than runs (default: 1.0)", + ) + p.add_argument( + "--csv", type=Path, default=None, help="Dump every ramp sample to this CSV" + ) + p.set_defaults(func=run) + + +def run(args: argparse.Namespace) -> None: + asyncio.run(_run(args)) + + +def _probe_poses( + joint: Joint, is_left: bool, requested: list[float] | None +) -> list[float]: + """Requested poses (rad), clamped inside the safe range with a margin, and + pushed outboard of a base-collision boundary.""" + lo, hi = safe_limits(joint, is_left) + margin = _CATCH + math.radians(2.0) + lo_ok, hi_ok = lo + margin, hi - margin + outboard = safe_outboard_direction(joint, is_left) + if outboard is not None: + if outboard > 0: + lo_ok = max(lo_ok, _BOUNDARY_MARGIN) + else: + hi_ok = min(hi_ok, -_BOUNDARY_MARGIN) + poses = [math.radians(d) for d in (requested if requested is not None else [0.0])] + out = [] + for q in poses: + clamped = min(max(q, lo_ok), hi_ok) + if abs(clamped - q) > 1e-9: + print( + f" ! pose {math.degrees(q):+.1f}° moved to {math.degrees(clamped):+.1f}° " + "(safe range with catch margin)" + ) + out.append(clamped) + return out + + +async def _run(args: argparse.Namespace) -> None: + joint = Joint(args.joint) + is_left = args.l + side = "left" if is_left else "right" + resolved = AxolConfig().resolved() + arm_cfg: ArmConfig = resolved.left if is_left else resolved.right + gains = getattr(arm_cfg, joint.value) + kp, kd = gains.kp, gains.kd + fc = gains.friction.fc + fc_ref = max(fc, _FC_FLOOR_NM) + peaks = peak_schedule(fc, args.max_torque) + move_thr = math.radians(args.move_deg) + poses = _probe_poses(joint, is_left, args.poses) + joint_index = ARM_JOINTS.index(joint) + + print(f"\nAxol breakaway (static friction) probe — {side} {joint.value}") + print( + f" config: kp={kp:g} kd={kd:g} fc={fc:.3f} Nm " + f"stiction_gain={gains.stiction_gain:g}" + ) + print( + f" ramp peaks: {', '.join(f'{p:.2f}' for p in peaks)} Nm over {args.ramp_s:g} s; " + f"release at {args.move_deg:g}°; probe kd={args.kd:g}" + ) + + gravity_comp = GravityCompensator(resolved) + other_targets, _lo, _hi, notes = sweep_safety(joint, is_left) + hold_q = np.zeros(len(ARM_JOINTS), dtype=np.float32) + for j, q in other_targets.items(): + hold_q[ARM_JOINTS.index(j)] = q + + def gravity_fn(q: float) -> float: + arm_q = hold_q.copy() + arm_q[joint_index] = q + return float(gravity_comp.gravity_arm(arm_q, is_left=is_left)[joint_index]) + + writer = csv_file = None + if args.csv is not None: + csv_file = open(args.csv, "w", newline="", encoding="utf-8") + writer = csv.writer(csv_file) + writer.writerow( + [ + "joint", + "side", + "pose_deg", + "direction", + "trial", + "peak_nm", + "t_s", + "extra_nm", + "pos_deg", + "tau_nm", + ] + ) + + results: list[dict] = [] + channel = resolve_channel(args) + async with CanBus(channel) as bus: + raw_motors = {j: Motor(bus, j) for j in ARM_JOINTS} + await asyncio.gather(*[m.enable() for m in raw_motors.values()]) + motors = await joint_frame_motors(raw_motors, is_left) + await asyncio.gather( + *[ + m.set_control_mode(ControlMode.POSITION_VELOCITY) + for m in motors.values() + ] + ) + motor = motors[joint] + try: + print(" Homing all joints to rest (distal to proximal) ...") + await _home_all(motors) + for note in notes: + print(f" {note}") + for stage in ramp_stages(other_targets): + await _ramp_verified(motors, stage) + await motor.set_control_mode(ControlMode.IMPEDANCE) + await asyncio.sleep(1.0) + # Prime the feedback cache at the current pose before any kp = 0. + here = await motor.get_position() + await _hold(motor, here, kp, kd, gravity_fn(here), 0.3) + + for pose in poses: + print( + f"\n pose {math.degrees(pose):+.1f}° (gravity model {gravity_fn(pose):+.2f} Nm)" + ) + await ramp_impedance(motor, kp, kd, pose, gravity_fn, rate_hz=_RATE_HZ) + await _hold(motor, pose, kp, kd, gravity_fn(pose), 0.5) + print(" trimming the hold (kp = 0) ...") + trim = await _trim(motor, pose, kp, kd, args.kd, gravity_fn, fc_ref) + if trim is None: + print( + " ! could not trim the joint still — gravity model too far off here; skipping pose" + ) + await _catch(motor, pose, kp, kd, gravity_fn) + continue + hold_ff = gravity_fn(pose) + trim + found: dict[int, list[float]] = {+1: [], -1: []} + for direction in (+1, -1): + start = 0 + for trial in range(args.trials): + released: float | None = None + for i in range(start, len(peaks)): + peak = peaks[i] + tag = ( + joint.value, + side, + f"{math.degrees(pose):.2f}", + direction, + trial, + f"{peak:.3f}", + ) + released = await _release( + motor, + pose, + direction, + peak, + args.ramp_s, + args.kd, + hold_ff, + move_thr, + writer, + tag, + ) + if released is not None: + print( + f" {'+' if direction > 0 else '-'} trial {trial + 1}: " + f"released at {released:.3f} Nm (ramp peak {peak:.2f})" + ) + found[direction].append(released) + start = max(0, i - 1) + await _catch(motor, pose, kp, kd, gravity_fn) + # The catch may have landed on a different + # edge of the stiction band; re-verify the trim. + trim = await _trim( + motor, + pose, + kp, + kd, + args.kd, + gravity_fn, + fc_ref, + trim0=trim, + ) + if trim is None: + raise RuntimeError("lost the hold trim mid-probe") + hold_ff = gravity_fn(pose) + trim + break + await _hold(motor, pose, 0.0, args.kd, hold_ff, 0.3) + if released is None: + print( + f" {'+' if direction > 0 else '-'} trial {trial + 1}: " + f"no release up to {peaks[-1]:.2f} Nm" + ) + if found[+1] and found[-1]: + f_static, bias = split_breakaway(found[+1], found[-1]) + stair = predicted_stair_deg(f_static, fc, kp) + results.append( + { + "pose_deg": math.degrees(pose), + "trim_nm": trim, + "plus": found[+1], + "minus": found[-1], + "f_static": f_static, + "bias": bias, + "ratio": f_static / fc if fc > 0 else math.nan, + "stair_deg": stair, + } + ) + else: + results.append( + { + "pose_deg": math.degrees(pose), + "trim_nm": trim, + "plus": found[+1], + "minus": found[-1], + } + ) + except KeyboardInterrupt: + print("\n Interrupted.") + finally: + if csv_file is not None: + csv_file.close() + print(" Returning to rest and disabling ...") + in_impedance = motor.motor.mode == ControlMode.IMPEDANCE + if in_impedance: + try: + here = motor.position + await _hold(motor, here, kp, kd, gravity_fn(here), 0.3) + await ramp_impedance( + motor, kp, kd, 0.0, gravity_fn, rate_hz=_RATE_HZ + ) + except Exception: # noqa: BLE001 - best-effort teardown + pass + try: + await _home_all(motors, exclude=joint if in_impedance else None) + except Exception: # noqa: BLE001 - best-effort teardown + pass + await asyncio.gather( + *[m.set_control_mode(ControlMode.IMPEDANCE) for m in motors.values()] + ) + await asyncio.gather(*[m.disable() for m in motors.values()]) + + _report(results, fc, kp, gains.stiction_gain) + + +def _report(results: list[dict], fc: float, kp: float, stiction_gain: float) -> None: + print(f"\n{'─' * 72}") + if not results: + print(" No poses probed.") + return + print( + f" {'pose':>7s} {'trim':>7s} {'F_static':>9s} {'bias':>7s} {'F_s/fc':>7s} {'stair':>7s} releases + / -" + ) + for r in results: + if "f_static" not in r: + print( + f" {r['pose_deg']:+7.1f} {r['trim_nm']:+7.3f} (incomplete: + {r['plus']} - {r['minus']})" + ) + continue + print( + f" {r['pose_deg']:+7.1f} {r['trim_nm']:+7.3f} {r['f_static']:9.3f} {r['bias']:+7.3f} " + f"{r['ratio']:7.2f} {r['stair_deg']:6.3f}° " + f"{' '.join(f'{v:.2f}' for v in r['plus'])} / {' '.join(f'{v:.2f}' for v in r['minus'])}" + ) + complete = [r for r in results if "f_static" in r] + if complete: + f_static = float(np.mean([r["f_static"] for r in complete])) + ratio = f_static / fc if fc > 0 else math.nan + print( + f"\n F_static ≈ {f_static:.3f} Nm vs sliding fc {fc:.3f} Nm (ratio {ratio:.2f})" + ) + print( + f" predicted stick-slip stair at kp={kp:g}: " + f"{predicted_stair_deg(f_static, fc, kp):.3f}° per stick" + ) + if fc > 0 and ratio > 1.05: + print( + f" stiction_gain ceiling (F_static/fc − 1): {ratio - 1:.2f}; " + f"currently {stiction_gain:g}. Try 60-80 % of the ceiling with " + "tune.motion --gain ..stiction_gain=…" + ) + elif fc > 0: + print( + " breakaway is at or below fc — the velocity feedforward already " + "covers static friction here; stiction_gain would over-compensate." + ) + print(f"{'─' * 72}") diff --git a/almond_axol/cli/tune/motion.py b/almond_axol/cli/tune/motion.py index 241ef8bb..49bb752b 100644 --- a/almond_axol/cli/tune/motion.py +++ b/almond_axol/cli/tune/motion.py @@ -36,6 +36,7 @@ axol tune.motion --motion reach-and-place --gain shoulder_3.kd_host=8 --label "s3 damp" axol tune.motion --motion reach-and-place --stiffness 0.8 axol tune.motion --motion reach-and-place --ik # drive through the IK solver + axol tune.motion --motion slow_osc --arms right # one arm only """ from __future__ import annotations @@ -66,6 +67,11 @@ "kd_host_hz", "kd_host_q", "j_eff", + "stiction_gain", + "stiction_load_gain", + "stiction_err_deg", + "dither_nm", + "dither_hz", ) # Column names of a 14-wide motion row: left arm then right arm. @@ -193,6 +199,25 @@ def add_parser(subparsers: argparse._SubParsersAction) -> None: # type: ignore[ action="store_true", help="Don't persist the run artifact (dry run)", ) + p.add_argument( + "--a4", + action="append", + default=[], + metavar="SIDE.JOINT", + help="Drive this MyActuator joint with the firmware's own position loop " + "(0xA4 absolute position closed-loop) instead of the MIT impedance frame, " + "e.g. right.shoulder_1. Repeatable. The joint then has no compliance, no " + "host feedforward and NaN torque telemetry (contact watchdog blind on it); " + "everything else about the replay is unchanged, so runs compare directly.", + ) + p.add_argument( + "--arms", + choices=("both", "left", "right"), + default="both", + help="Which arm(s) to bring up and drive (default: both). The other " + "arm's channel is left untouched, so a single-arm bench or an " + "unpowered arm does not block the run.", + ) p.add_argument( "--no-gripper", action="store_true", @@ -415,6 +440,15 @@ async def _run(args: argparse.Namespace) -> None: for (side, joint, fld), value in overrides.items(): setattr(getattr(getattr(config, side), joint), fld, value) print(f" gain override: {side}.{joint}.{fld} = {value}") + for spec in args.a4: + parts = spec.split(".") + if len(parts) != 2 or parts[0] not in ("left", "right"): + raise SystemExit(f"--a4 wants SIDE.JOINT, got {spec!r}") + side, joint = parts + if joint not in {j.value for j in ARM_JOINTS}: + raise SystemExit(f"--a4: unknown joint {joint!r}") + getattr(getattr(config, side), joint).wire_mode = "a4" + print(f" wire mode: {side}.{joint} = a4 (firmware position loop)") # The kinematics stack plans the collision-aware approach/return moves. print("Loading kinematics solver (JIT compile may take a few seconds) ...") @@ -529,7 +563,12 @@ async def execute( traj_playback = [to_full(row) for row in sent] # Production playback always runs through the Rust core, matching teleop. - robot = Axol(config=config) + arm_channels: dict[str, None] = {} + if args.arms == "right": + arm_channels["left_channel"] = None + elif args.arms == "left": + arm_channels["right_channel"] = None + robot = Axol(config=config, **arm_channels) async with robot as axol: contact: tuple[str, float] | None = None diff --git a/almond_axol/robot/axol.py b/almond_axol/robot/axol.py index 0b25a8f5..b70a0c3d 100644 --- a/almond_axol/robot/axol.py +++ b/almond_axol/robot/axol.py @@ -40,12 +40,15 @@ from .base import RobotBase, mark_hardware_cleanup_uncertain from .config import AxolConfig from .control import ( + BandPass, DAMP_BP_Q, DAMP_BP_W0, - VEL_CUTOFF_FREQ, - BandPass, Differentiator, + TorqueDither, + VEL_CUTOFF_FREQ, compute_friction, + stiction_amplitude, + stiction_compensation, ) from .gravity import GravityCompensator @@ -648,6 +651,7 @@ def __init__( for j in Joint ] self._damp_bp = BandPass(n=n_j, w0=self._damp_w0, q=self._damp_q) + self._dither = TorqueDither(len(ARM_JOINTS)) self._last_q_commanded: np.ndarray | None = None self._gc_hold_q: np.ndarray | None = None self._gc_hold_free: frozenset[Joint] | None = None @@ -1662,6 +1666,10 @@ async def motion_control(self, q: np.ndarray) -> None: return arm_cmds: list[tuple[float, float, float, float, float]] = [] + dither = self._dither.update( + [getattr(self._arm_config, j.value).dither_nm for j in ARM_JOINTS], + [getattr(self._arm_config, j.value).dither_hz for j in ARM_JOINTS], + ) for i, j in enumerate(ARM_JOINTS): gains = getattr(self._arm_config, j.value) f = gains.friction @@ -1672,9 +1680,34 @@ async def motion_control(self, q: np.ndarray) -> None: # phase-safe on the slow shoulder modes, so silently converting # excess firmware damping into it could excite the very # oscillation the oversized kd was meant to kill. + # Stiction compensation acts on the measured error (motor frame + # on both sides); zero until the first feedback frame is cached. + stiction = 0.0 + if gains.stiction_gain != 0.0 or gains.stiction_load_gain != 0.0: + motor = self.motors.get(j) + try: + q_meas = motor.position if motor is not None else None + v_meas = motor.velocity if motor is not None else 0.0 + except MotorError: + q_meas = None + v_meas = 0.0 + if q_meas is not None: + stiction = stiction_compensation( + float(motor_targets[i]) - q_meas, + v_meas, + stiction_amplitude( + f.fc, + gains.stiction_gain, + gains.stiction_load_gain, + float(gravity[i]), + ), + math.radians(gains.stiction_err_deg), + ) t_ff = ( float(gravity[i]) + compute_friction(velocities[i], f.fc, f.k, f.fv, f.fo) + + stiction + + dither[i] + gains.j_eff * float(j_scale[i]) * accelerations[i] + float(host_scale[i]) * gains.kd_host * v_damp[i] ) @@ -1852,6 +1885,7 @@ def reset_command_state(self) -> None: self._vel_fast_diff = Differentiator(n=n, cutoff=VEL_CUTOFF_FREQ) self._meas_vel_diff = Differentiator(n=n, cutoff=VEL_CUTOFF_FREQ) self._damp_bp = BandPass(n=n, w0=self._damp_w0, q=self._damp_q) + self._dither = TorqueDither(len(ARM_JOINTS)) def torque_residuals(self) -> np.ndarray: """Measured minus model-gravity torque per arm joint, shape (7,). diff --git a/almond_axol/robot/config.py b/almond_axol/robot/config.py index 955477d2..ce2e768d 100644 --- a/almond_axol/robot/config.py +++ b/almond_axol/robot/config.py @@ -165,6 +165,56 @@ class JointConfig: q=3 on both arms even with its pose-tracked centre: hardware traces found a separate 12.5-13.6 Hz mast/forearm mode that the old wide band could feed. + stiction_gain: Error-sign Coulomb compensation, as a fraction of + ``friction.fc`` (see + :func:`almond_axol.robot.control.stiction_compensation`). + ``0`` (default) is the production law. Pushes up to + ``gain·fc`` toward the target while the joint is stuck and + the velocity feedforward has switched itself off, fading + out as that feedforward saturates — the lever for the + slow-motion stick-slip stairs of the high-ratio X8-P20 + shoulders (0.3-0.6° at 2 Hz on right shoulder_1, where + breakaway is ~2.3× the fitted sliding ``fc``). Keep it + below the breakaway/``fc`` ratio ``axol tune.breakaway`` + measures, or the term hunts around the target at rest. + stiction_load_gain: Load-proportional part of that push, in Nm per + Nm of the joint's gravity feedforward: the peak push is + ``stiction_gain·fc + stiction_load_gain·|gravity|``. + Gear friction follows the transmitted torque — right + shoulder_1 broke away at 0.66 Nm at rest but at 2-3.3 Nm + under 10-15 Nm of gravity load — so a constant push + either hunts at rest or does nothing extended. Size it + from ``tune.breakaway --poses`` at loaded poses, or from + the excess torque at release in a slow replay trace. + stiction_err_deg: Position error (degrees) at which that term + saturates. Smaller is a stiffer push-off; 0.1° is a few + encoder LSBs above the feedback noise floor. + dither_nm: Peak amplitude (Nm) of a sinusoidal torque dither on the + feedforward (see :func:`almond_axol.robot.control.dither_step`); + ``0`` (default) off. Keeps a geared joint's meshes sliding + so its velocity-weakening friction cannot re-stick between + cycles — the lever for the X8-P20 shoulders' 2 Hz + stick-slip once feedforward and stiction compensation + have shrunk the stairs as far as they can. Start at 1-2 Nm + on a shoulder; it is audible. + dither_hz: Dither frequency; above the arm's structural modes + (~35 Hz), below the core's 120 Hz Nyquist. + wire_mode: Which frame the realtime core commands this joint with + while tracking (MyActuator joints only; Damiao joints, + the gripper, gravity comp and the limp fallback always use + MIT). ``"mit"`` (default) is the impedance frame and the + production law. ``"a4"`` hands the joint to the firmware's + own position loop (0xA4 absolute position closed-loop, + speed-capped at the tracker's velocity limit): its kHz + position/speed PI on the motor-side encoder is the + candidate for creeping through the X8-P20's stick-slip. + Costs: no compliance (the joint holds position with + integral action and pushes back up to motor torque), no + host feedforward (gravity, friction, stiction, dither and + damping are all inert), and no torque telemetry — the + reply carries q-axis current, so measured torque reads + NaN and the contact watchdog is blind on that joint. + Position stays 0.01° via a paired 0x92 read each tick. """ kp: float @@ -176,6 +226,12 @@ class JointConfig: kd_host: float = 0.0 kd_host_hz: float | None = None kd_host_q: float | None = None + stiction_gain: float = 0.0 + stiction_load_gain: float = 0.0 + stiction_err_deg: float = 0.1 + dither_nm: float = 0.0 + dither_hz: float = 60.0 + wire_mode: str = "mit" @dataclass @@ -412,7 +468,20 @@ def _calibrated_joint(jc: JointConfig, entry: dict[str, Any]) -> JointConfig: """Overlay one joint's calibration-file entry onto its config.""" overrides: dict[str, Any] = { f: entry[f] - for f in ("kp", "kd", "j_eff", "kd_host", "kd_host_hz", "kd_host_q") + for f in ( + "kp", + "kd", + "j_eff", + "kd_host", + "kd_host_hz", + "kd_host_q", + "stiction_gain", + "stiction_load_gain", + "stiction_err_deg", + "dither_nm", + "dither_hz", + "wire_mode", + ) if f in entry } friction = entry.get("friction") diff --git a/almond_axol/robot/control.py b/almond_axol/robot/control.py index cccb03a0..e43a1c34 100644 --- a/almond_axol/robot/control.py +++ b/almond_axol/robot/control.py @@ -1,4 +1,4 @@ -"""Motor control utilities: friction model, differentiator, contact watchdog. +"""Motor control utilities: friction/stiction models, differentiator, contact watchdog. Gravity compensation is handled separately — see :class:`almond_axol.robot.gravity.GravityCompensator` — because the simple @@ -139,6 +139,16 @@ def update(self, residuals, now: float | None = None) -> tuple[str, float] | Non FRICTION_FF_K_MAX = 100.0 +def coulomb_unit(velocity: float, k: float) -> float: + """Saturation of the Coulomb feedforward in ``[-1, 1]``: ``tanh(0.1·k·v)``. + + The factor :func:`compute_friction` multiplies ``Fc`` by, with the same + :data:`FRICTION_FF_K_MAX` cap. Exposed so :func:`stiction_compensation` + can fade itself out exactly as the velocity term takes over. + """ + return math.tanh(0.1 * min(k, FRICTION_FF_K_MAX) * velocity) + + def compute_friction( velocity: float, Fc: float, k: float, Fv: float, Fo: float ) -> float: @@ -147,9 +157,114 @@ def compute_friction( ``k`` is capped at :data:`FRICTION_FF_K_MAX` (see above) so the Coulomb term ramps smoothly through zero crossings instead of stepping. """ - return ( - Fc * math.tanh(0.1 * min(k, FRICTION_FF_K_MAX) * velocity) + Fv * velocity + Fo - ) + return Fc * coulomb_unit(velocity, k) + Fv * velocity + Fo + + +def stiction_amplitude( + Fc: float, gain: float, load_gain: float, gravity: float +) -> float: + """Peak stiction push (Nm): ``gain·Fc + load_gain·|gravity|``. + + Gear friction is not a constant: the breakaway measured on right + shoulder_1 (X8-P20, 1:20) was 0.66 Nm at the rest pose but 2-3.3 Nm with + the arm extended under 10-15 Nm of gravity load — the transmitted torque + loads the gear meshes and their static friction scales with it. A push + sized for the loaded pose would hunt at rest, one sized for rest does + nothing under load, so the amplitude follows the gravity feedforward + the joint is carrying this cycle (``load_gain`` in Nm per Nm). + """ + return gain * Fc + load_gain * abs(gravity) + + +# Measured joint speed (rad/s) over which the stiction push fades out — +# ``1 − tanh(|v_meas| / STICTION_FADE_VEL)``. About 1.4 LSB of the motor's +# reported velocity (0.022 rad/s): a joint reading one LSB of motion keeps +# ~40 % of the push, two LSBs ~10 %. The fade used to follow the *commanded* +# velocity, which kept the push on through the whole slip phase of a slow +# move (the command is slow, so the velocity feedforward never saturates), +# so the joint was driven past the target, the push flipped sign and it +# re-stuck — a 2 Hz limit cycle in place of the stairs. Measured velocity +# hands over to the sliding feedforward the moment the joint actually moves. +STICTION_FADE_VEL = 0.03 + +# Per-channel phase offset of the torque dither (rad): the golden angle, +# π(3 − √5), so seven joints never push the structure in unison. +DITHER_PHASE_STAGGER = math.pi * (3.0 - math.sqrt(5.0)) + + +def stiction_compensation( + err: float, v_meas: float, amp: float, err_scale: float +) -> float: + """Error-sign Coulomb compensation: + ``amp·tanh(err/err_scale)·(1 − tanh(|v_meas|/STICTION_FADE_VEL))``. + + The velocity feedforward above is driven by the *commanded* velocity, so + at the creeping speeds where a geared joint stick-slips (the X8-P20 + shoulders below ~0.1 rad/s) it delivers well under half of ``Fc`` and + nothing at all while the joint sits stuck with the target walking away + from it. Breakaway there costs ``(F_static − F_ff)/kp`` of tracking + error — the 0.5°, 2 Hz stairs measured on right shoulder_1 — and a pure + velocity feedforward cannot shrink them: whatever level it holds, the + joint still jumps by ``(F_static − F_kinetic)/kp`` when it lets go. + + This term acts on the *measured* error instead (``err = q_des − q_meas``): + it pushes up to ``amp`` (see :func:`stiction_amplitude`) toward the + target while the joint is stuck, saturating within ``err_scale`` so the + effective stiffness near zero error is far above ``kp`` and breakaway + happens after a fraction of the stair. It fades on the *measured* + velocity (:data:`STICTION_FADE_VEL`), so it is gone as soon as the joint + slides and the velocity feedforward takes over — it never drives the + slip phase. + + Keep ``amp`` below the breakaway torque ``axol tune.breakaway`` measures + at the corresponding load (compensation that exceeds the real static + friction hunts around the target at rest). ``amp == 0`` (the default on + every joint) disables the term exactly. + """ + if amp == 0.0: + return 0.0 + fade = 1.0 - math.tanh(abs(v_meas) / STICTION_FADE_VEL) + return amp * math.tanh(err / max(err_scale, 1e-9)) * fade + + +def dither_step(phase: float, nm: float, hz: float, dt: float) -> tuple[float, float]: + """Advance a torque-dither oscillator one step: ``(new_phase, torque)``. + + A small sinusoidal torque on the feedforward keeps a geared joint's + meshes in the sliding regime instead of letting them re-stick between + control cycles. The X8-P20 shoulders' friction is velocity-weakening + (2.1 Nm sliding at 0.05 rad/s, 0.7 Nm at 0.2 rad/s, 2.4-3.3 Nm static + under load), which is what turns a slow move into a 2 Hz stick-slip + cycle no feedforward can stabilise; dither flattens that curve at the + contact. ``hz`` has to clear the arm's structural modes (up to ~35 Hz) + and stay under the Nyquist of the loop emitting it (120 Hz in the core), + so 50-80 Hz. ``nm == 0`` (the default on every joint) is exactly zero and + leaves the phase alone. + """ + if nm == 0.0 or hz <= 0.0: + return phase, 0.0 + phase = math.fmod(phase + 2.0 * math.pi * hz * dt, 2.0 * math.pi) + return phase, nm * math.sin(phase) + + +class TorqueDither: + """N-channel torque dither (see :func:`dither_step`), each channel a golden + angle further round the cycle. Sample spacing comes from the wall clock, + like :class:`BandPass`.""" + + def __init__(self, n: int) -> None: + self._phase = [i * DITHER_PHASE_STAGGER for i in range(n)] + self._last: float | None = None + + def update(self, nm: Sequence[float], hz: Sequence[float]) -> list[float]: + now = time.perf_counter() + dt = 0.0 if self._last is None else now - self._last + self._last = now + out: list[float] = [] + for i in range(len(self._phase)): + self._phase[i], torque = dither_step(self._phase[i], nm[i], hz[i], dt) + out.append(torque) + return out class BandPass: diff --git a/almond_axol/rt/link.py b/almond_axol/rt/link.py index f955c3ff..958ab6aa 100644 --- a/almond_axol/rt/link.py +++ b/almond_axol/rt/link.py @@ -41,7 +41,7 @@ #: both together whenever the config or target layout changes meaning — a #: package and a binary from different checkouts must fail at configure #: time, not arm and then silently reject every target. -CONFIG_PROTO = 2 +CONFIG_PROTO = 6 def config_header() -> list[str]: diff --git a/almond_axol/rt/robot.py b/almond_axol/rt/robot.py index dbc32cc7..815e2fbb 100644 --- a/almond_axol/rt/robot.py +++ b/almond_axol/rt/robot.py @@ -95,6 +95,9 @@ # ``VRTeleopConfig.reset_gravity_comp_kd`` (the classic contact hold). _LIMP_KD = 0.25 +# Wire-mode tokens the core understands (``bringup::WireMode::parse``). +_WIRE_MODES = frozenset({"mit", "a4"}) + class Axol(RobotBase): """Dual-arm Axol robot interface. @@ -307,6 +310,14 @@ def _arms(self) -> list[tuple[int, AxolArm]]: return out def _config_text(self) -> str: + def _wire_token(mode: str) -> str: + token = str(mode).lower() + if token not in _WIRE_MODES: + raise ValueError( + f"wire_mode {mode!r} is not one of {sorted(_WIRE_MODES)}" + ) + return token + max_step = self._arms()[0][1]._config.max_step_rad lines = [ *config_header(), @@ -333,7 +344,10 @@ def _config_text(self) -> str: lines.append( f"joint {side} {iface} {j.value} {motor_id} " f"{gains.kp} {gains.kd} {trk_vel} {trk_acc} " - f"{f.fc} {f.k} {f.fv} {f.fo}" + f"{f.fc} {f.k} {f.fv} {f.fo} " + f"{gains.stiction_gain} {math.radians(gains.stiction_err_deg)} " + f"{gains.stiction_load_gain} {gains.dither_nm} {gains.dither_hz} " + f"{_wire_token(gains.wire_mode)}" ) if arm._has_gripper: lines.append( @@ -341,6 +355,22 @@ def _config_text(self) -> str: ) return "\n".join(lines) + "\n" + def _warn_wire_modes(self) -> None: + a4 = [ + f"{'left' if side == 0 else 'right'}.{j.value}" + for side, arm in self._arms() + for j in ARM_JOINTS + if j in arm.motors + and str(getattr(arm._arm_config, j.value).wire_mode).lower() == "a4" + ] + if a4: + _logger.warning( + "rt: %s on the firmware position loop (wire_mode a4): no " + "compliance, no host feedforward, torque telemetry NaN — the " + "contact watchdog cannot see these joints", + ", ".join(a4), + ) + async def enable(self, hold: bool = True) -> None: """Bring every motor up. @@ -365,6 +395,7 @@ async def enable(self, hold: bool = True) -> None: self._require_quiet_bus("enable(hold=False)") await self._robot.enable(hold=False) return + self._warn_wire_modes() try: await self._enable() except BaseException as setup_error: diff --git a/almond_axol/serve/commands.py b/almond_axol/serve/commands.py index 48e83245..c61f5ae1 100644 --- a/almond_axol/serve/commands.py +++ b/almond_axol/serve/commands.py @@ -563,6 +563,24 @@ def load() -> Any: hardware_profiles=("axol",), section="tuning", ), + "tune.a4": CommandDef( + "tune.a4", + "tune.a4", + "Firmware position loop (0xA4)", + "Tune a MyActuator joint's own position/speed loop — the controller " + "behind wire_mode a4 — with a sine or constant-speed triangle: set " + "firmware gains (RAM unless persisted), the speed cap and the " + "planner acceleration, score tracking and creep smoothness, and " + "save the run. A buzz guard restores the previous gains on any " + "high-frequency motion.", + "Diagnostics", + "argparse", + _argparse_loader("..cli.tune.a4"), + requires_hardware=True, + drives_motors=True, + hardware_profiles=("axol",), + section="tuning", + ), "tune.friction": CommandDef( "tune.friction", "tune.friction", diff --git a/almond_axol/teleop/recorder.py b/almond_axol/teleop/recorder.py index 39bfb165..9a5f60fb 100644 --- a/almond_axol/teleop/recorder.py +++ b/almond_axol/teleop/recorder.py @@ -82,6 +82,8 @@ "friction_ff", "inertia_ff", "damping_ff", + "stiction_ff", + "dither_ff", "total_ff", "kd_host", "damp_w0", diff --git a/almond_axol/tuning/motions/slow_osc.npz b/almond_axol/tuning/motions/slow_osc.npz new file mode 100644 index 0000000000000000000000000000000000000000..2d07589c1e8f849821a5c7fa6be301fb7aba3fb0 GIT binary patch literal 324059 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%0A@!z=u{_`BKTUzBIp~FZ~?o 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≪a@}w8Xr*qmbotox!9IvD^ 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

K;3!M3=QVT%md!nM~H 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 literal 0 HcmV?d00001 diff --git a/docs/cli/tune-a4.mdx b/docs/cli/tune-a4.mdx new file mode 100644 index 00000000..0f35ad5a --- /dev/null +++ b/docs/cli/tune-a4.mdx @@ -0,0 +1,48 @@ +--- +title: "tune.a4" +description: "Tune a MyActuator joint's firmware position loop (0xA4) with a sine or constant-speed triangle." +--- + +Tunes the **firmware** position loop of one MyActuator joint — the controller the realtime core hands a joint to when its `wire_mode` is `a4` (see the [config reference](/cli/configuration)). Streams a sine or a constant-speed triangle target over 0xA4 at `--rate` Hz with the firmware gains, 0xA4 speed cap and planner acceleration you choose, reads the fine 0.01° position (0x92) every cycle, scores tracking and creep smoothness, and saves the run for the diagnostics dashboard (Tuning → Firmware loop). Also available from the dashboard (`axol serve`). + +[`tune.pid`](/cli/tune-pid) tunes the MIT impedance frame, whose gains are the host's. Under 0xA4 the whole controller is the motor's own position PI → speed PI → current loop, so the knobs here are the firmware gains (`position_kp/ki/kd`, `speed_kp/ki`, `current_kp/ki`), the speed cap, and the position planner's acceleration: **0 puts the loop in direct PI tracking of the stream; any other value re-plans every streamed target and the joint will not follow the wave at all.** + +The triangle is the stick-slip probe: every pass runs at one creep speed, so the behaviour is not confined to the sine's turnarounds. Compare runs on **velocity ripple** (std of measured minus commanded velocity over the commanded speed: the MIT frame's stick-slip sits near 0.8, smooth is under 0.2), **stuck windows**, the **1–4 Hz error band**, **lag** and **>10 Hz buzz**. + +Safety, built in: + +- Gains are written to **RAM** (0x31) unless `--persist`, and the pre-run values are written back when the run ends; `--keep` leaves a winner in place. Gains are written *after* homing and the mode switch, since those reset the motor and reload ROM. +- A **buzz guard** aborts on high-frequency position motion or excess current and restores the previous gains at once. Start every sweep from the stock values in small steps: shoulder_1 at 3× stock `speed_kp` vibrated immediately. +- The joint holds position stiffly in this mode and pushes back against contact up to motor torque. Keep the workspace clear and the e-stop in reach. + +| Flag | Description | +|---|---| +| `--l` / `--r` | Arm side (required) | +| `--channel IFACE` | SocketCAN interface override | +| `--joint JOINT` | MyActuator joint: `shoulder_1`, `shoulder_2`, `shoulder_3`, `elbow`, `wrist_1` (required) | +| `--mode sine\|triangle` | Wave shape (default: `triangle`) | +| `--center DEG` | Centre, joint-frame degrees (default: midpoint of the safe range) | +| `--amp DEG` | Half-travel, degrees (default: 10) | +| `--freq HZ` | Sine frequency (default: 0.3) | +| `--speed DPS` | Triangle pass speed, deg/s (default: 3) | +| `--duration S` | Seconds of wave (default: 12) | +| `--rate HZ` | Command rate (default: 200) | +| `--cap DPS` | 0xA4 speed cap (default: 60) | +| `--accel DPS/S` | Planner acceleration for the run; `0` = direct tracking. Written to ROM, restored afterwards unless `--keep` | +| `--position-kp`, `--position-ki`, `--position-kd`, `--speed-kp`, `--speed-ki`, `--current-kp`, `--current-ki` | Firmware gains for the run (default: leave as is) | +| `--persist` | Write gains to ROM instead of RAM | +| `--keep` | Leave the run's gains and planner acceleration in the motor | +| `--buzz-abort DEG` | Abort past this >10 Hz position motion, degrees RMS over 0.1 s (default: 0.3; 0 off) | +| `--iq-abort A` | Abort past this reply current (default: 10; 0 off) | +| `--save-run` | Persist the run artifact | +| `--label TEXT`, `--group ID` | Note and sweep id stored on the run | + +```bash +axol tune.a4 --r --joint shoulder_1 --center -35 --amp 10 --mode triangle --speed 3 --accel 0 --save-run +axol tune.a4 --r --joint shoulder_1 --accel 0 --speed-kp 0.05 --speed-ki 0.0005 --save-run --label "skp 0.05" +axol tune.a4 --r --joint shoulder_1 --mode sine --freq 0.3 --accel 0 --position-kp 0.02 --save-run +``` + + + A joint left with planner acceleration 0 executes any stored position target at the speed cap the moment it wakes. `tune.a4` restores the stored acceleration unless you pass `--keep`; if you keep it, do not drive that joint with any other position command until you have set it back. + diff --git a/docs/cli/tune-breakaway.mdx b/docs/cli/tune-breakaway.mdx new file mode 100644 index 00000000..b52adb85 --- /dev/null +++ b/docs/cli/tune-breakaway.mdx @@ -0,0 +1,35 @@ +--- +title: "tune.breakaway" +description: "Measure a joint's static (breakaway) friction and compare it to its sliding fc." +--- + +Measures the torque at which a stationary joint first moves — its **breakaway** or static friction — in both directions, and compares it to the sliding Coulomb torque `fc` the friction model already carries. + +[`tune.friction`](/cli/tune-friction) cannot answer this: its model `fc·tanh(0.1·k·v) + fv·v` is monotonic in velocity and has no static/kinetic distinction, and its sweep bottoms out around 0.13 rad/s, above the creep speeds where a geared joint stick-slips. Stick-slip exists precisely *because* breakaway exceeds sliding friction — a joint tracking a slow target sticks until `kp·err` covers the gap, lets go, overshoots and re-sticks. On the X8-P20 shoulders that is a 2 Hz, ~0.5° stair pattern during slow extended-reach moves. This command measures the gap directly, with no velocity signal at all. + +**Method, per pose.** The arm is homed and the [sweep-safety](/cli/tune-friction) clearances applied; the test joint ramps to the pose under impedance control, then drops to `kp = 0` with a small `kd` so only the gravity feed-forward holds it. That feed-forward is trimmed until the joint stands still (a joint sits still for any trim inside its stiction band, so this converges quickly — and without it a loaded pose is unmeasurable, since a 2 % gravity-model error at 15 Nm outweighs the whole breakaway torque). An extra torque is then ramped up and down in a triangle; the first motion past `--move-deg` is the release, the extra torque at that instant is recorded, and the joint is caught under `kp` and returned to the pose. Peaks escalate over attempts and stop at the first release, so the joint never sees more torque than it took to move it. + +**What comes out.** Releases in both directions split into a symmetric half — **F_static**, the friction — and an antisymmetric half, the standing torque the trimmed feed-forward still missed. The report gives `F_static / fc`, the predicted stick-slip stair `(F_static − fc) / kp` in degrees (compare it to the stairs a slow [`tune.motion`](/cli/tune-motion) replay shows), and the ceiling for the joint's `stiction_gain` (`F_static / fc − 1`): compensation that exceeds the friction it compensates hunts around the target at rest, so start at 60–80 % of that ceiling. + +| Flag | Description | +|---|---| +| `--l` / `--r` | Arm side (required) | +| `--channel IFACE` | SocketCAN interface override for setups without the Axol hub adapter | +| `--joint JOINT` | `shoulder_1`, `shoulder_2`, `shoulder_3`, `elbow`, `wrist_1`, `wrist_2`, `wrist_3` (required) | +| `--poses DEG [DEG ...]` | Joint-frame poses (degrees, 0 = rest) to probe (default: 0). Base-collision joints are pushed 5° outboard of their boundary so a release cannot cross it | +| `--trials N` | Releases per direction (default: 3) | +| `--ramp-s S` | Seconds for one up-and-back torque ramp (default: 4) | +| `--max-torque M` | Largest ramp peak, as a multiple of the joint's `fc` (floored at 0.2 Nm for the low-friction wrists; default: 3.0) | +| `--move-deg DEG` | Motion that counts as a release — about 3 LSB of the 16-bit MIT position (default: 0.06) | +| `--kd KD` | Firmware damping during the `kp = 0` phases, so a released joint creeps rather than runs (default: 1.0) | +| `--csv PATH` | Dump every ramp sample (`t, extra_nm, pos_deg, tau_nm`) for offline plotting | + +```bash +axol tune.breakaway --r --joint shoulder_1 +axol tune.breakaway --r --joint shoulder_2 --trials 5 --csv ~/breakaway-s2.csv +axol tune.breakaway --r --joint shoulder_1 --poses -30 0 30 # load dependence +``` + + + The joint under test spends most of the run with **no position spring** — only a trimmed gravity feed-forward and light damping hold it. Keep the workspace around the arm clear and stay within reach of the e-stop, as for every tuning probe. + diff --git a/docs/cli/tune-motion.mdx b/docs/cli/tune-motion.mdx index f9901c2f..3bcd61bf 100644 --- a/docs/cli/tune-motion.mdx +++ b/docs/cli/tune-motion.mdx @@ -12,7 +12,7 @@ The arm moves to the motion's start and back to rest on collision-aware planned | Flag | Description | |---|---| | `--motion NAME` | Committed motion name (`axol motion.list`) or a path to a motion `.npz` (required) | -| `--gain [SIDE.]JOINT.FIELD=VALUE` | Override one gain for this run, e.g. `left.elbow.kd=4.5` or `shoulder_3.kd_host=8` (no side = both arms). Fields: `kp`, `kd`, `kd_host`, `kd_host_hz`, `kd_host_q`, `j_eff`. Repeatable | +| `--gain [SIDE.]JOINT.FIELD=VALUE` | Override one gain for this run, e.g. `left.elbow.kd=4.5` or `shoulder_3.kd_host=8` (no side = both arms). Fields: `kp`, `kd`, `kd_host`, `kd_host_hz`, `kd_host_q`, `j_eff`, `stiction_gain`, `stiction_load_gain`, `stiction_err_deg`, `dither_nm`, `dither_hz`. Repeatable | | `--stiffness S` | Stiffness-slider position in `[0, 1]` for both arms (default: 1.0, the production default — the tuned gains, where gain overrides land exactly; lower only adds compliance) | | `--ik` | Drive the run through the IK solver: each waypoint's end-effector poses (FK of the reference, with elbow hints) are re-solved to joints exactly like teleop's pose→joints loop, and the arms execute the *solver's* output — still scored against the clean reference, so IK reconstruction error and tracking error show up together. Per-solve times and the solved-vs-reference deviation are stored on the run (`ik_solve_ms_*`, `ik_dev_*`) | | `--noise MODE` | Corrupt the motion before streaming it, at the noise source's real pipeline entry point: `network` (jitter/outliers/stalls), `ik` (solver churn/jumps), `combined`, or `none` (default). Deterministic per `--seed` | @@ -21,6 +21,8 @@ The arm moves to the motion's start and back to rest on collision-aware planned | `--label TEXT` | Free-form note stored on the run artifact | | `--torque-threshold NM` | Contact watchdog threshold (default: 8.0; 0 disables) | | `--no-save-run` | Don't persist the run artifact (dry run) | +| `--a4 SIDE.JOINT` | Drive one MyActuator joint with the firmware position loop (0xA4) instead of the MIT frame for this run, e.g. `right.shoulder_1`; repeatable. No compliance, no host feed-forward, NaN torque on that joint — see `wire_mode` in the [config reference](/cli/configuration) | +| `--arms both\|left\|right` | Which arm(s) to bring up and drive (default: `both`). The other arm's bus is left untouched, so an unpowered arm or a single-arm bench does not block the run | | `--no-gripper` | Run on the gripperless SKU | ```bash diff --git a/docs/docs.json b/docs/docs.json index 14c4f5c4..33dac650 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -176,6 +176,8 @@ "pages": [ "cli/tune-pid", "cli/tune-friction", + "cli/tune-breakaway", + "cli/tune-a4", "cli/tune-gravity", "cli/tune-factory", "cli/tune-motion", diff --git a/docs/snippets/config/robot.mdx b/docs/snippets/config/robot.mdx index 578da421..f9809fe3 100644 --- a/docs/snippets/config/robot.mdx +++ b/docs/snippets/config/robot.mdx @@ -14,6 +14,12 @@ export const F = ({ children }) => {child | --{prefix}left.elbow.kd_host_hz | `null` | Centre frequency (Hz) of the band the host damping acts in. `null` tracks the arm's pose automatically. | | --{prefix}left.elbow.kd_host_q | `null` | Width of that band (bandwidth = centre ÷ q). `null` uses `0.8`. | | --{prefix}left.elbow.j_eff | per joint | Effective inertia (kg·m²) used for acceleration feed-forward. | +| --{prefix}left.elbow.stiction_gain | `0.0` | Error-sign Coulomb compensation as a fraction of `friction.fc`: pushes up to `gain × fc` toward the target while the joint is stuck and the velocity feed-forward is off, fading out once the joint slides. The lever for slow-motion stick-slip stairs on the high-ratio X8 shoulders; keep it below the breakaway/`fc` ratio [`tune.breakaway`](/cli/tune-breakaway) measures. `0` is the production law. | +| --{prefix}left.elbow.stiction_load_gain | `0.0` | Load-proportional part of that push, in Nm per Nm of the joint's gravity feed-forward (peak push = `stiction_gain × fc + stiction_load_gain × |gravity|`). Gear friction follows the transmitted torque — right shoulder_1 breaks away at 0.66 Nm at rest but at 2–3 Nm with the arm extended — so a constant push either hunts at rest or does nothing under load. | +| --{prefix}left.elbow.stiction_err_deg | `0.1` | Position error (degrees) at which that term saturates. The push fades on *measured* velocity (gone above ~0.05 rad/s), so it never drives the slip. | +| --{prefix}left.elbow.dither_nm | `0.0` | Peak amplitude (Nm) of a sinusoidal torque dither on the feed-forward, `0` off. Keeps a geared joint's meshes sliding so its velocity-weakening friction (the X8-P20 shoulders: 2.1 Nm sliding at 0.05 rad/s, 0.7 Nm at 0.2 rad/s) cannot re-stick between cycles. Start at 1–2 Nm on a shoulder; it is audible. | +| --{prefix}left.elbow.dither_hz | `60.0` | Dither frequency: above the arm's structural modes (~35 Hz), below the core's 120 Hz Nyquist. | +| --{prefix}left.elbow.wire_mode | `mit` | Frame the realtime core commands a **MyActuator** joint with while tracking. `mit` is the impedance frame (production). `a4` hands the joint to the firmware's own position loop (0xA4, speed-capped at the tracker limit) with a paired 0x92 read for 0.01° position — the candidate for creeping through X8-P20 stick-slip. Costs: no compliance, no host feed-forward, and NaN torque telemetry (the contact watchdog is blind on that joint). Damiao joints, the gripper, gravity comp and the limp fallback always use MIT. | | --{prefix}left.elbow.mass | per joint | Mass (kg) of the link, used for gravity compensation. Tune if an arm sags or pushes back in [gravity comp](/operations/gravity-comp). | | --{prefix}left.elbow.com | per joint | Centre of mass `[x, y, z]` (m) of the link. | | --{prefix}left.elbow.friction.fc | per motor | Coulomb friction (Nm). Measured per motor with [`tune.friction`](/cli/tune-friction). | diff --git a/rust/axol-rt/README.md b/rust/axol-rt/README.md index 426284cc..99ec2999 100644 --- a/rust/axol-rt/README.md +++ b/rust/axol-rt/README.md @@ -242,7 +242,7 @@ AXOL_RT_TRACE=/tmp/axol-run axol teleop Each 240 Hz joint row includes the streamed target, wire position/velocity, measured position/velocity/torque, filter states, and separate gravity, -friction, inertia, and host-damping torque contributions. The bus threads +friction, inertia, host-damping, and stiction torque contributions. The bus threads enqueue fixed-size rows into bounded channels; background threads format and write them, and the regular five-second status line reports any trace drops. diff --git a/rust/axol-rt/src/bringup.rs b/rust/axol-rt/src/bringup.rs index 3eabed8d..8998c738 100644 --- a/rust/axol-rt/src/bringup.rs +++ b/rust/axol-rt/src/bringup.rs @@ -40,6 +40,18 @@ pub struct MotorSpec { pub k: f64, pub fv: f64, pub fo: f64, + /// Error-sign stiction compensation (`filter::stiction`): gain as a + /// fraction of `fc`, and the error (rad) it saturates at. Zero gain is + /// the production law; zero for the gripper. + pub stiction_gain: f64, + pub stiction_err: f64, + /// Load-proportional stiction push, Nm per Nm of gravity feedforward. + pub stiction_load_gain: f64, + /// Torque dither (`filter::dither_step`): peak Nm (0 off) and frequency. + pub dither_nm: f64, + pub dither_hz: f64, + /// Command frame for tracked ticks (MyActuator joints only). + pub wire: WireMode, } #[derive(Clone, Copy, PartialEq)] @@ -48,6 +60,32 @@ pub enum Vendor { Damiao, } +/// Which frame a MyActuator arm joint is commanded with in tracked mode. +/// Damiao joints, the gripper, and every passthrough/limp tick use MIT +/// regardless. +#[derive(Clone, Copy, PartialEq, Debug)] +pub enum WireMode { + /// The 0x400 impedance frame: the production control law. + Mit, + /// 0xA4 absolute position closed-loop: the firmware's own position PI + /// (and speed PI beneath it, at its kHz rate) tracks the streamed target + /// under a speed cap. No host feedforward reaches the motor; gravity and + /// friction are the firmware integrator's job. Paired with a 0x92 read + /// per tick for 0.01° position; the reply's torque channel is iq in + /// amps, so measured torque is reported as NaN on these joints. + A4, +} + +impl WireMode { + pub fn parse(token: &str) -> Option { + match token { + "mit" => Some(Self::Mit), + "a4" => Some(Self::A4), + _ => None, + } + } +} + /// A motor that passed bring-up prep: identified, fault-free, ranges known. #[derive(Clone)] pub struct ReadyMotor { @@ -73,6 +111,12 @@ pub struct ReadyMotor { pub k: f64, pub fv: f64, pub fo: f64, + pub stiction_gain: f64, + pub stiction_err: f64, + pub stiction_load_gain: f64, + pub dither_nm: f64, + pub dither_hz: f64, + pub wire: WireMode, } /// Status-probe attempts before a silent motor fails the bring-up. @@ -211,6 +255,12 @@ pub fn prepare(sock: &CanSock, iface: &str, specs: &[MotorSpec]) -> io::Result io::Result f64 { - fc * (0.1 * k.min(FRICTION_FF_K_MAX) * v).tanh() + fv * v + fo + fc * coulomb_unit(v, k) + fv * v + fo +} + +/// Saturation of the Coulomb feedforward in `[-1, 1]` — `coulomb_unit` in +/// `almond_axol.robot.control`, with the same `FRICTION_FF_K_MAX` cap. +pub fn coulomb_unit(v: f64, k: f64) -> f64 { + (0.1 * k.min(FRICTION_FF_K_MAX) * v).tanh() +} + +/// Peak stiction push (Nm) — `stiction_amplitude` in +/// `almond_axol.robot.control`: `gain·fc + load_gain·|gravity|`. Gear +/// friction follows the transmitted torque (right shoulder_1 broke away at +/// 0.66 Nm at rest, 2-3.3 Nm under 10-15 Nm of gravity), so the push tracks +/// the gravity feedforward the joint carries this tick. +pub fn stiction_amplitude(fc: f64, gain: f64, load_gain: f64, gravity: f64) -> f64 { + gain * fc + load_gain * gravity.abs() +} + +/// Measured speed (rad/s) the stiction push fades over — +/// `STICTION_FADE_VEL` in `almond_axol.robot.control` (~1.4 LSB of the +/// motor-reported velocity). +pub const STICTION_FADE_VEL: f64 = 0.03; + +/// Error-sign Coulomb compensation — `stiction_compensation` in +/// `almond_axol.robot.control`: +/// `amp·tanh(err/err_scale)·(1 − tanh(|v_meas|/STICTION_FADE_VEL))`. Pushes +/// toward the target (`err = q_des − q_meas`) while the joint is stuck, and +/// is gone as soon as the joint measurably slides — it must never drive the +/// slip phase (fading on the *commanded* velocity did, and turned the +/// stairs into a 2 Hz limit cycle). `amp == 0` is exactly zero. +pub fn stiction(err: f64, v_meas: f64, amp: f64, err_scale: f64) -> f64 { + if amp == 0.0 { + return 0.0; + } + let fade = 1.0 - (v_meas.abs() / STICTION_FADE_VEL).tanh(); + amp * (err / err_scale.max(1e-9)).tanh() * fade +} + +/// Per-slot phase offset of the torque dither, the golden angle π(3 − √5) — +/// `DITHER_PHASE_STAGGER` in `almond_axol.robot.control`. +pub const DITHER_PHASE_STAGGER: f64 = 2.399_963_229_728_653; + +/// Advance a torque-dither oscillator one step — `dither_step` in +/// `almond_axol.robot.control`. Returns the torque; `phase` is advanced in +/// place. `nm == 0` is exactly zero and leaves the phase alone. +pub fn dither_step(phase: &mut f64, nm: f64, hz: f64, dt: f64) -> f64 { + if nm == 0.0 || hz <= 0.0 { + return 0.0; + } + *phase = (*phase + 2.0 * std::f64::consts::PI * hz * dt) % (2.0 * std::f64::consts::PI); + nm * phase.sin() } /// Velocity/acceleration-limited target tracker — the per-joint @@ -577,6 +627,73 @@ mod tests { /// Golden values from `almond_axol.robot.control.compute_friction` /// with fc=0.6, k=250 (above the cap), fv=0.15, fo=0.02. + /// Reference vectors from `almond_axol.robot.control.dither_step`: + /// 1.5 Nm at 60 Hz stepped at 240 Hz, slots 0 and 1. + #[test] + fn dither_matches_python() { + let golden: [(usize, [f64; 4]); 2] = [ + ( + 0, + [1.5, 8.498308346471969e-16, -1.5, -1.6996616692943939e-15], + ), + ( + 1, + [ + -1.1060533171174791, + -1.0132354413922864, + 1.106053317117479, + 1.0132354413922875, + ], + ), + ]; + for (slot, want) in golden { + let mut phase = slot as f64 * DITHER_PHASE_STAGGER; + for (k, w) in want.iter().enumerate() { + let got = dither_step(&mut phase, 1.5, 60.0, 1.0 / 240.0); + assert!( + (got - w).abs() < 1e-12, + "slot {slot} k {k}: got {got:e}, want {w:e}" + ); + } + } + let mut phase = 1.0; + assert_eq!(dither_step(&mut phase, 0.0, 60.0, 0.01), 0.0); + assert_eq!(phase, 1.0); + } + + /// Reference vectors from `almond_axol.robot.control.stiction_compensation` + /// (amp = 0.36, err_scale = 0.1°): `(err, v_meas, want)`. + #[test] + fn stiction_matches_python() { + let scale = 0.0017453292519943296_f64; + let golden = [ + (-0.02, 0.0, -0.3599999999198255), + (-0.002, 0.0, -0.29390273095808195), + (-0.0005, 0.0, -0.10040068111546155), + (0.0, 0.0, 0.0), + (0.0005, 0.0, 0.10040068111546155), + (0.002, 0.0, 0.29390273095808195), + (0.02, 0.0, 0.3599999999198255), + (0.02, 0.022, 0.1349638509268763), + (0.02, -0.044, 0.03638171680139523), + (0.02, 0.15, 3.268646545848154e-05), + ]; + for (err, v, want) in golden { + let amp = stiction_amplitude(0.6, 0.6, 0.0, 0.0); + let got = stiction(err, v, amp, scale); + assert!( + (got - want).abs() < 1e-12, + "stiction({err}, v={v}): got {got:e}, want {want:e}" + ); + } + // Off by default: a zero amplitude is exactly 0. + assert_eq!(stiction_amplitude(0.6, 0.0, 0.0, 12.0), 0.0); + assert_eq!(stiction(0.02, 0.0, 0.0, scale), 0.0); + // The push follows |gravity|: 0.39 Nm at rest, 2.79 Nm under 12 Nm. + assert!((stiction_amplitude(1.3, 0.3, 0.2, -12.0) - 2.79).abs() < 1e-12); + assert!((stiction_amplitude(1.3, 0.3, 0.2, 0.0) - 0.39).abs() < 1e-12); + } + #[test] fn friction_matches_python() { let golden = [ diff --git a/rust/axol-rt/src/hold.rs b/rust/axol-rt/src/hold.rs index c0e50d27..ee63d6a4 100644 --- a/rust/axol-rt/src/hold.rs +++ b/rust/axol-rt/src/hold.rs @@ -17,7 +17,7 @@ use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; use std::time::{Duration, Instant}; -use crate::bringup::{self, MotorSpec, ReadyMotor, Vendor}; +use crate::bringup::{self, MotorSpec, ReadyMotor, Vendor, WireMode}; use crate::can::CanSock; use crate::proto; use crate::safety::{guarded_send, purge_tx_queue, SendOutcome, STALL_DETECT}; @@ -73,6 +73,12 @@ pub fn parse_params(path: &str) -> io::Result> { k: 0.0, fv: 0.0, fo: 0.0, + stiction_gain: 0.0, + stiction_err: 0.0, + stiction_load_gain: 0.0, + dither_nm: 0.0, + dither_hz: 0.0, + wire: WireMode::Mit, }, t_ff: fields.get(5)?.parse().ok()?, }) diff --git a/rust/axol-rt/src/proto.rs b/rust/axol-rt/src/proto.rs index cddb15ad..e0613441 100644 --- a/rust/axol-rt/src/proto.rs +++ b/rust/axol-rt/src/proto.rs @@ -127,6 +127,33 @@ pub fn ma_decode_version(data: &[u8; 8]) -> u32 { u32::from_le_bytes([data[4], data[5], data[6], data[7]]) } +/// 0xA4 absolute position closed-loop command: `[0xA4, 0, cap_lo, cap_hi, +/// p0, p1, p2, p3]` — a uint16 output-shaft speed cap (dps) and the int32 +/// target (0.01 deg/LSB, motor frame). Sent to 0x140 + id; the reply comes +/// on 0x240 + id (see [`ma_decode_a4_reply`]). +pub fn ma_a4_encode(p_des: f64, cap_dps: f64) -> [u8; 8] { + let cap = cap_dps.clamp(0.0, u16::MAX as f64).round() as u16; + let pos = (p_des.to_degrees() * 100.0) + .round() + .clamp(i32::MIN as f64, i32::MAX as f64) as i32; + let c = cap.to_le_bytes(); + let p = pos.to_le_bytes(); + [0xA4, 0x00, c[0], c[1], p[0], p[1], p[2], p[3]] +} + +/// The 0x92 multi-turn angle request, paired with an 0xA4 command so the +/// host still gets 0.01 deg position (the 0xA4 reply's own angle is 1 deg). +pub const MA_MULTI_TURN_REQUEST: [u8; 8] = [MA_MULTI_TURN_ANGLE, 0, 0, 0, 0, 0, 0, 0]; + +/// Decode a 0xA4 (or 0xA2/0xA1 — same layout) control reply: `(iq A, +/// speed rad/s, angle rad)`. The angle is the reply's int16 whole degrees. +pub fn ma_decode_a4_reply(data: &[u8; 8]) -> (f64, f64, f64) { + let iq = i16::from_le_bytes([data[2], data[3]]) as f64 * 0.01; + let speed_dps = i16::from_le_bytes([data[4], data[5]]) as f64; + let angle_deg = i16::from_le_bytes([data[6], data[7]]) as f64; + (iq, speed_dps.to_radians(), angle_deg.to_radians()) +} + /// Decode a 0x92 reply: multi-turn angle in radians (0.01 deg/LSB). pub fn ma_decode_position(data: &[u8; 8]) -> f64 { let raw = i32::from_le_bytes([data[4], data[5], data[6], data[7]]); @@ -297,6 +324,30 @@ mod tests { /// Reference vector generated by the Python driver's encoder /// (`damiao._float_to_uint` + the IMPEDANCE frame layout) for /// p=1.2345, v=-0.5, kp=130, kd=3, t_ff=2.75 at Damiao default ranges. + /// Vendor manual §2.20 example 1: 500 dps cap, +360° target; reply 50 °C, + /// 1.00 A, 500 dps, +45°. + #[test] + fn a4_frames_match_the_vendor_example() { + assert_eq!( + ma_a4_encode(2.0 * std::f64::consts::PI, 500.0), + [0xA4, 0x00, 0xF4, 0x01, 0xA0, 0x8C, 0x00, 0x00] + ); + assert_eq!( + ma_a4_encode(-2.0 * std::f64::consts::PI, 500.0), + [0xA4, 0x00, 0xF4, 0x01, 0x60, 0x73, 0xFF, 0xFF] + ); + let (iq, speed, angle) = + ma_decode_a4_reply(&[0xA4, 0x32, 0x64, 0x00, 0xF4, 0x01, 0x2D, 0x00]); + assert!((iq - 1.0).abs() < 1e-9); + assert!((speed - 500.0_f64.to_radians()).abs() < 1e-9); + assert!((angle - 45.0_f64.to_radians()).abs() < 1e-9); + let (iq, speed, angle) = + ma_decode_a4_reply(&[0xA4, 0x32, 0x9C, 0xFF, 0x0C, 0xFE, 0xD3, 0xFF]); + assert!((iq + 1.0).abs() < 1e-9); + assert!((speed + 500.0_f64.to_radians()).abs() < 1e-9); + assert!((angle + 45.0_f64.to_radians()).abs() < 1e-9); + } + #[test] fn mit_encode_matches_python() { let ranges = MitRanges { diff --git a/rust/axol-rt/src/serve.rs b/rust/axol-rt/src/serve.rs index 143fbac3..2c29f0b0 100644 --- a/rust/axol-rt/src/serve.rs +++ b/rust/axol-rt/src/serve.rs @@ -198,7 +198,7 @@ use std::sync::{Arc, Condvar, Mutex}; use std::thread::JoinHandle; use std::time::{Duration, Instant}; -use crate::bringup::{self, MotorSpec, Vendor}; +use crate::bringup::{self, MotorSpec, Vendor, WireMode}; use crate::can::CanSock; use crate::filter::{self, BandPass, Cadence, Holdover, LpDiff, Trapezoid}; use crate::hold::sleep_until; @@ -238,7 +238,12 @@ const HOLDOVER_MAX: f64 = 0.080; /// - 1 (implicit; never declared): arm joints took slots in list order. /// - 2: slots come from the motor id (`slot = motor_id - 1`), so a bus may /// carry any subset of the arm. -const CONFIG_PROTO: u32 = 2; +/// - 3: each `joint` line carries two more fields, the stiction +/// compensation gain and its error scale (`filter::stiction`). +/// - 4: plus the load-proportional stiction gain (`filter::stiction_amplitude`). +/// - 5: plus the torque dither amplitude and frequency (`filter::dither_step`). +/// - 6: plus the wire mode token (`mit` | `a4`, `bringup::WireMode`). +const CONFIG_PROTO: u32 = 6; /// Rolling feedback loss at or above this many misses in the last 32 ticks /// (12.5% over 133 ms at 240 Hz) marks a joint *degraded*: its host damping /// stays off until a full clean window has passed, and the transition is @@ -401,19 +406,27 @@ fn next_bus_deadline(began: Instant, period: Duration) -> Instant { began + period } -/// Accept at most one reply from each motor commanded in this tick. +/// Accept at most as many replies from each motor as it was sent commands +/// this tick (one for MIT, two for an 0xA4 joint: the command reply and the +/// 0x92 position read). /// /// CAN frames carry no command sequence number. The bus loop therefore drains -/// late frames before sending and uses this per-batch set to prevent duplicate -/// or unsolicited feedback from satisfying another motor's reply budget. -fn mark_unique_expected_reply(expected: &[bool], seen: &mut [bool], idx: usize) -> bool { - if idx >= expected.len() || idx >= seen.len() || !expected[idx] || seen[idx] { +/// late frames before sending and uses this per-batch budget to prevent +/// duplicate or unsolicited feedback from satisfying another motor's reply +/// budget. +fn mark_unique_expected_reply(expected: &[u8], seen: &mut [u8], idx: usize) -> bool { + if idx >= expected.len() || idx >= seen.len() || seen[idx] >= expected[idx] { return false; } - seen[idx] = true; + seen[idx] += 1; true } +/// Every reply the motor was budgeted for this tick arrived. +fn reply_complete(expected: &[u8], seen: &[u8], idx: usize) -> bool { + expected[idx] > 0 && seen[idx] >= expected[idx] +} + /// Outcome of one feedback opportunity for one arm joint. #[derive(Clone, Copy, Debug, PartialEq, Eq)] enum FeedbackVerdict { @@ -619,6 +632,8 @@ struct TraceRow { friction_ff: f64, inertia_ff: f64, damping_ff: f64, + stiction_ff: f64, + dither_ff: f64, total_ff: f64, kd_host: f64, damp_w0: f64, @@ -640,7 +655,7 @@ fn trace_file(path: &PathBuf) -> io::Result> { let mut out = io::BufWriter::new(std::fs::File::create(path)?); writeln!( out, - "tick,time_s,seq,slot,motor_id,mode,target_p,cmd_p,cmd_v,cmd_a,cmd_v_fast,meas_p,motor_v,meas_v,meas_tau,gravity_ff,friction_ff,inertia_ff,damping_ff,total_ff,kd_host,damp_w0,damp_q,tick_dt,fb_dt" + "tick,time_s,seq,slot,motor_id,mode,target_p,cmd_p,cmd_v,cmd_a,cmd_v_fast,meas_p,motor_v,meas_v,meas_tau,gravity_ff,friction_ff,inertia_ff,damping_ff,stiction_ff,dither_ff,total_ff,kd_host,damp_w0,damp_q,tick_dt,fb_dt" )?; Ok(out) } @@ -648,7 +663,7 @@ fn trace_file(path: &PathBuf) -> io::Result> { fn write_trace_row(out: &mut io::BufWriter, r: TraceRow) -> io::Result<()> { writeln!( out, - "{},{:.9},{},{},{},{:.1},{:.12},{:.12},{:.12},{:.12},{:.12},{:.12},{:.12},{:.12},{:.12},{:.12},{:.12},{:.12},{:.12},{:.12},{:.12},{:.12},{:.12},{:.9},{:.9}", + "{},{:.9},{},{},{},{:.1},{:.12},{:.12},{:.12},{:.12},{:.12},{:.12},{:.12},{:.12},{:.12},{:.12},{:.12},{:.12},{:.12},{:.12},{:.12},{:.12},{:.12},{:.12},{:.12},{:.9},{:.9}", r.tick, r.time_s, r.seq, @@ -668,6 +683,8 @@ fn write_trace_row(out: &mut io::BufWriter, r: TraceRow) -> io::R r.friction_ff, r.inertia_ff, r.damping_ff, + r.stiction_ff, + r.dither_ff, r.total_ff, r.kd_host, r.damp_w0, @@ -877,6 +894,8 @@ fn parse_config(text: &str) -> io::Result { "joint" | "gripper" => { // joint // + // + // // gripper let gripper = f[0] == "gripper"; let side: u8 = f @@ -913,6 +932,12 @@ fn parse_config(text: &str) -> io::Result { k: 0.0, fv: 0.0, fo: 0.0, + stiction_gain: 0.0, + stiction_err: 0.0, + stiction_load_gain: 0.0, + dither_nm: 0.0, + dither_hz: 0.0, + wire: WireMode::Mit, } } else { let motor_id: u8 = f @@ -940,6 +965,15 @@ fn parse_config(text: &str) -> io::Result { k: num(10)?, fv: num(11)?, fo: num(12)?, + stiction_gain: num(13)?, + stiction_err: num(14)?, + stiction_load_gain: num(15)?, + dither_nm: num(16)?, + dither_hz: num(17)?, + wire: f + .get(18) + .and_then(|t| WireMode::parse(t)) + .ok_or_else(|| bad(line))?, } }; if spec.slot >= N_SLOTS || bus.2.iter().any(|s| s.slot == spec.slot) { @@ -1085,13 +1119,21 @@ mod tests { #[test] fn replies_must_be_expected_and_unique() { - let expected = [true, true, false]; - let mut seen = [false; 3]; + let expected = [1u8, 2, 0]; + let mut seen = [0u8; 3]; assert!(mark_unique_expected_reply(&expected, &mut seen, 0)); assert!(!mark_unique_expected_reply(&expected, &mut seen, 0)); assert!(!mark_unique_expected_reply(&expected, &mut seen, 2)); + // An 0xA4 joint is budgeted two replies (command echo + 0x92 read) + // and is only complete once both are in. + assert!(mark_unique_expected_reply(&expected, &mut seen, 1)); + assert!(!reply_complete(&expected, &seen, 1)); assert!(mark_unique_expected_reply(&expected, &mut seen, 1)); - assert_eq!(seen, [true, true, false]); + assert!(reply_complete(&expected, &seen, 1)); + assert!(!mark_unique_expected_reply(&expected, &mut seen, 1)); + assert!(reply_complete(&expected, &seen, 0)); + assert!(!reply_complete(&expected, &seen, 2)); + assert_eq!(seen, [1, 2, 0]); } #[test] @@ -1340,12 +1382,12 @@ mod tests { #[test] fn parse_config_assigns_slots() { let cfg = parse_config( - "proto 2\n\ + "proto 6\n\ loop_hz 240\n\ - joint 0 canL shoulder_1 1 250 3.5 9.4 33.0 0.6 250 0.15 0.02\n\ - joint 0 canL shoulder_2 2 250 3.5 9.4 33.0 0.5 250 0.10 0.0\n\ + joint 0 canL shoulder_1 1 250 3.5 9.4 33.0 0.6 250 0.15 0.02 0 0 0 0 60 mit\n\ + joint 0 canL shoulder_2 2 250 3.5 9.4 33.0 0.5 250 0.10 0.0 0 0 0 0 60 mit\n\ gripper 0 canL 8\n\ - joint 0 canL shoulder_3 3 180 2.0 9.4 33.0 0.4 250 0.08 0.0\n", + joint 0 canL shoulder_3 3 180 2.0 9.4 33.0 0.4 250 0.08 0.0 0.6 0.0017 0.2 1.5 60 a4\n", ) .unwrap(); let specs = &cfg.buses[0].2; @@ -1360,9 +1402,46 @@ mod tests { (specs[0].fc, specs[0].k, specs[0].fv, specs[0].fo), (0.6, 250.0, 0.15, 0.02) ); + assert_eq!((specs[0].stiction_gain, specs[0].stiction_err), (0.0, 0.0)); + assert_eq!( + (specs[3].stiction_gain, specs[3].stiction_err), + (0.6, 0.0017) + ); + assert_eq!( + (specs[0].stiction_load_gain, specs[3].stiction_load_gain), + (0.0, 0.2) + ); + assert_eq!((specs[0].dither_nm, specs[0].dither_hz), (0.0, 60.0)); + assert_eq!((specs[3].dither_nm, specs[3].dither_hz), (1.5, 60.0)); + assert_eq!( + (specs[0].wire, specs[3].wire), + (WireMode::Mit, WireMode::A4) + ); + // An unknown wire token is a bad line, not a silent MIT. + assert!(parse_config( + "proto 6\njoint 0 canL shoulder_1 1 250 3.5 9.4 33.0 0.6 250 0.15 0.02 0 0 0 0 60 a9\n" + ) + .is_err()); // A joint line missing the tracker/friction params (the previous // 7-field layout) must be rejected, not defaulted. - assert!(parse_config("proto 2\njoint 0 canL shoulder_1 1 250 3.5\n").is_err()); + assert!(parse_config("proto 6\njoint 0 canL shoulder_1 1 250 3.5\n").is_err()); + // ... and so must the proto-2/3/4/5 layouts (13, 15, 16 or 18 fields). + assert!(parse_config( + "proto 6\njoint 0 canL shoulder_1 1 250 3.5 9.4 33.0 0.6 250 0.15 0.02\n" + ) + .is_err()); + assert!(parse_config( + "proto 6\njoint 0 canL shoulder_1 1 250 3.5 9.4 33.0 0.6 250 0.15 0.02 0 0\n" + ) + .is_err()); + assert!(parse_config( + "proto 6\njoint 0 canL shoulder_1 1 250 3.5 9.4 33.0 0.6 250 0.15 0.02 0 0 0\n" + ) + .is_err()); + assert!(parse_config( + "proto 6\njoint 0 canL shoulder_1 1 250 3.5 9.4 33.0 0.6 250 0.15 0.02 0 0 0 0 60\n" + ) + .is_err()); } /// A bus carrying only some of the arm joints (a bench wrist assembly) @@ -1371,9 +1450,9 @@ mod tests { #[test] fn parse_config_subset_keeps_joint_slots() { let cfg = parse_config( - "proto 2\n\ - joint 0 can0 wrist_2 6 40 1.0 9.4 33.0 0.0 0.0 0.0 0.0\n\ - joint 0 can0 wrist_3 7 40 1.0 9.4 33.0 0.0 0.0 0.0 0.0\n\ + "proto 6\n\ + joint 0 can0 wrist_2 6 40 1.0 9.4 33.0 0.0 0.0 0.0 0.0 0 0 0 0 60 mit\n\ + joint 0 can0 wrist_3 7 40 1.0 9.4 33.0 0.0 0.0 0.0 0.0 0 0 0 0 60 mit\n\ gripper 0 can0 8\n", ) .unwrap(); @@ -1384,12 +1463,18 @@ mod tests { ); // Arm joint ids outside 1..=7 have no slot; a repeated id would // double-book one. - assert!(parse_config("proto 2\njoint 0 can0 wrist_3 8 40 1.0 9.4 33.0 0 0 0 0\n").is_err()); - assert!(parse_config("proto 2\njoint 0 can0 bogus 0 40 1.0 9.4 33.0 0 0 0 0\n").is_err()); assert!(parse_config( - "proto 2\n\ - joint 0 can0 wrist_2 6 40 1.0 9.4 33.0 0 0 0 0\n\ - joint 0 can0 wrist_2 6 40 1.0 9.4 33.0 0 0 0 0\n" + "proto 6\njoint 0 can0 wrist_3 8 40 1.0 9.4 33.0 0 0 0 0 0 0 0 0 60 mit\n" + ) + .is_err()); + assert!(parse_config( + "proto 6\njoint 0 can0 bogus 0 40 1.0 9.4 33.0 0 0 0 0 0 0 0 0 60 mit\n" + ) + .is_err()); + assert!(parse_config( + "proto 6\n\ + joint 0 can0 wrist_2 6 40 1.0 9.4 33.0 0 0 0 0 0 0 0 0 60 mit\n\ + joint 0 can0 wrist_2 6 40 1.0 9.4 33.0 0 0 0 0 0 0 0 0 60 mit\n" ) .is_err()); } @@ -1400,7 +1485,7 @@ mod tests { /// target on the max-step gate — the arms enabled and never moved. #[test] fn parse_config_requires_matching_proto() { - let joint = "joint 0 can0 wrist_2 6 40 1.0 9.4 33.0 0 0 0 0\n"; + let joint = "joint 0 can0 wrist_2 6 40 1.0 9.4 33.0 0 0 0 0 0 0 0 0 60 mit\n"; let error_of = |text: &str| match parse_config(text) { Ok(_) => panic!("accepted a skewed config: {text:?}"), Err(err) => err.to_string(), @@ -1410,14 +1495,14 @@ mod tests { assert!(err.contains("no `proto` line"), "{err}"); assert!(err.contains("axol rt.install"), "{err}"); // A future client generation this core does not understand. - let err = error_of(&format!("proto 3\n{joint}")); - assert!(err.contains("proto 3"), "{err}"); - assert!(err.contains("proto 2"), "{err}"); + let err = error_of(&format!("proto 99\n{joint}")); + assert!(err.contains("proto 99"), "{err}"); + assert!(err.contains("proto 6"), "{err}"); // Malformed declarations are bad lines, not silently accepted. assert!(parse_config(&format!("proto\n{joint}")).is_err()); assert!(parse_config(&format!("proto two\n{joint}")).is_err()); // Order does not matter; the line just has to be there. - assert!(parse_config(&format!("{joint}proto 2\n")).is_ok()); + assert!(parse_config(&format!("{joint}proto 6\n")).is_ok()); } } @@ -1920,9 +2005,12 @@ fn bus_loop( bp: BandPass, vel_meas: f64, last_fb: Option, + /// Torque-dither oscillator phase (`filter::dither_step`), started a + /// golden angle apart per slot. + dither_phase: f64, } let mut damp: Vec = (0..N_SLOTS) - .map(|_| Damp { + .map(|slot| Damp { v_cmd: LpDiff::new(CONTROL_CUTOFF), a_cmd: LpDiff::new(CONTROL_CUTOFF), v_cmd_fast: LpDiff::new(VEL_CUTOFF), @@ -1930,6 +2018,7 @@ fn bus_loop( bp: BandPass::new(), vel_meas: 0.0, last_fb: None, + dither_phase: slot as f64 * filter::DITHER_PHASE_STAGGER, }) .collect(); let mut prev_tick: Option = None; @@ -1983,8 +2072,13 @@ fn bus_loop( let mut stall_probe = stall::StallProbe::open(); // Per-tick reply bookkeeping, allocated once: the loop must not grow // the heap (a fresh page is a fault, see `stall::lock_memory`). - let mut expected = vec![false; motors.len()]; - let mut seen = vec![false; motors.len()]; + let mut expected = vec![0u8; motors.len()]; + let mut seen = vec![0u8; motors.len()]; + // 0xA4 joints: the command reply arrives before the 0x92 position read; + // its speed and iq are staged here until the fine position completes + // the sample. + let mut a4_stage: [(f64, f64); N_SLOTS] = [(0.0, 0.0); N_SLOTS]; + let mut a4_follow = vec![false; motors.len()]; // Belt-and-braces: sends on a dead bus normally fail fast with ENOBUFS, // but if the socket sndbuf fills first a blocking write would hang the // loop; the timeout turns that into EAGAIN (treated as TX-full). @@ -2260,7 +2354,7 @@ fn bus_loop( // Send all commands back-to-back and remember exactly which // motors were successfully queued in this tick. - expected.fill(false); + expected.fill(0); let mut trace_pending: [Option; N_SLOTS] = [None; N_SLOTS]; for (motor_index, m) in motors.iter().enumerate() { let c = if is_limp && !m.gripper { @@ -2325,57 +2419,101 @@ fn bus_loop( c.p_des }; let d = &mut damp[m.slot]; - let (v_wire, a_cmd, v_cmd_fast, friction_ff, inertia_ff, v_damp) = - if tracked && overrun { - // The gap since the last command is not a trajectory - // segment the motor followed — it held. Re-prime the - // derivative chains at rest here so the first tick - // back carries no fictitious velocity, acceleration - // (inertia torque), or band-pass energy; they ramp - // in again from the next tick as tracking resumes. - d.v_cmd.seed(p_cmd); - d.a_cmd.seed(0.0); - d.v_cmd_fast.seed(p_cmd); - d.bp.reset(); - (0.0, 0.0, 0.0, 0.0, 0.0, 0.0) - } else if tracked { - // Match classic AxolArm.motion_control: friction uses - // the 20 rad/s low-pass position derivative, inertia - // uses a second identical derivative, and damping - // uses its independent 80 rad/s desired-velocity - // derivative. Only the source position/rate differ: - // the core can use the trajectory it really sends. - let v_cmd = d.v_cmd.update(p_cmd, tick_dt); - let a_cmd = d.a_cmd.update(v_cmd, tick_dt); - let v_cmd_fast = d.v_cmd_fast.update(p_cmd, tick_dt); - let friction_ff = filter::friction(v_cmd, m.fc, m.k, m.fv, m.fo); - let inertia_ff = c.j_eff * a_cmd; - let damp_ok = feedback_fresh[m.slot] - && timing_on_time - && !timing_health.degraded - && !feedback_health[m.slot].degraded; - let v_damp = if damp_ok { - d.bp.update(v_cmd_fast - d.vel_meas, c.damp_w0, c.damp_q, tick_dt) - } else { - // A missing frame makes measured velocity stale. - // Reset rather than carrying band-pass energy into - // the first tick after feedback recovers. While the - // joint is degraded, damping stays off for the whole - // stretch: re-engaging a freshly reset band-pass - // every few ticks is a torque transient, not damping. - d.bp.reset(); - 0.0 - }; - (v_cmd, a_cmd, v_cmd_fast, friction_ff, inertia_ff, v_damp) + let ( + v_wire, + a_cmd, + v_cmd_fast, + friction_ff, + inertia_ff, + v_damp, + stiction_ff, + dither_ff, + ) = if tracked && overrun { + // The gap since the last command is not a trajectory + // segment the motor followed — it held. Re-prime the + // derivative chains at rest here so the first tick + // back carries no fictitious velocity, acceleration + // (inertia torque), or band-pass energy; they ramp + // in again from the next tick as tracking resumes. + d.v_cmd.seed(p_cmd); + d.a_cmd.seed(0.0); + d.v_cmd_fast.seed(p_cmd); + d.bp.reset(); + (0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0) + } else if tracked { + // Match classic AxolArm.motion_control: friction uses + // the 20 rad/s low-pass position derivative, inertia + // uses a second identical derivative, and damping + // uses its independent 80 rad/s desired-velocity + // derivative. Only the source position/rate differ: + // the core can use the trajectory it really sends. + let v_cmd = d.v_cmd.update(p_cmd, tick_dt); + let a_cmd = d.a_cmd.update(v_cmd, tick_dt); + let v_cmd_fast = d.v_cmd_fast.update(p_cmd, tick_dt); + let friction_ff = filter::friction(v_cmd, m.fc, m.k, m.fv, m.fo); + // Stiction compensation acts on the measured error + // against the latest accepted feedback. It is a slow + // term (it resolves a stuck joint over tens of ms), + // so a one-tick-old sample is fine, but a joint with + // no fresh reply this tick gets none rather than a + // push computed from a stale position. + let stiction_ff = match latest[m.slot] { + Some((pos, vel, _, _)) if feedback_fresh[m.slot] => filter::stiction( + p_cmd - pos, + vel, + filter::stiction_amplitude( + m.fc, + m.stiction_gain, + m.stiction_load_gain, + c.t_ff, + ), + m.stiction_err, + ), + _ => 0.0, + }; + let dither_ff = filter::dither_step( + &mut d.dither_phase, + m.dither_nm, + m.dither_hz, + tick_dt, + ); + let inertia_ff = c.j_eff * a_cmd; + let damp_ok = feedback_fresh[m.slot] + && timing_on_time + && !timing_health.degraded + && !feedback_health[m.slot].degraded; + let v_damp = if damp_ok { + d.bp.update(v_cmd_fast - d.vel_meas, c.damp_w0, c.damp_q, tick_dt) } else { - d.v_cmd.seed(p_cmd); - d.a_cmd.seed(0.0); - d.v_cmd_fast.seed(p_cmd); + // A missing frame makes measured velocity stale. + // Reset rather than carrying band-pass energy into + // the first tick after feedback recovers. While the + // joint is degraded, damping stays off for the whole + // stretch: re-engaging a freshly reset band-pass + // every few ticks is a torque transient, not damping. d.bp.reset(); - (0.0, 0.0, 0.0, 0.0, 0.0, 0.0) + 0.0 }; + ( + v_cmd, + a_cmd, + v_cmd_fast, + friction_ff, + inertia_ff, + v_damp, + stiction_ff, + dither_ff, + ) + } else { + d.v_cmd.seed(p_cmd); + d.a_cmd.seed(0.0); + d.v_cmd_fast.seed(p_cmd); + d.bp.reset(); + (0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0) + }; let damping_ff = c.kd_host * v_damp; - let t_ff = c.t_ff + friction_ff + inertia_ff + damping_ff; + let t_ff = + c.t_ff + friction_ff + stiction_ff + dither_ff + inertia_ff + damping_ff; if trace_this_tick && trace_tx.is_some() { trace_pending[m.slot] = Some(TraceRow { tick: ticks, @@ -2398,6 +2536,8 @@ fn bus_loop( friction_ff, inertia_ff, damping_ff, + stiction_ff, + dither_ff, total_ff: t_ff, kd_host: c.kd_host, damp_w0: c.damp_w0, @@ -2406,15 +2546,28 @@ fn bus_loop( fb_dt: f64::NAN, }); } - let frame = proto::mit_encode(p_cmd, v_wire, c.kp, c.kd, t_ff, &m.ranges); - let arb = match m.vendor { - Vendor::MyActuator => proto::MA_MC_REQ + m.id as u16, - Vendor::Damiao => m.id as u16, - }; - (arb, frame) + if tracked && m.vendor == Vendor::MyActuator && m.wire == WireMode::A4 { + // Firmware position loop: the streamed trajectory as + // an absolute 0.01° target under the tracker's own + // velocity limit as the speed cap. No feedforward + // reaches the wire; the 0x92 read below restores + // fine position to the host. + a4_follow[motor_index] = true; + ( + proto::MA_REQ + m.id as u16, + proto::ma_a4_encode(p_cmd, m.max_vel.to_degrees()), + ) + } else { + let frame = proto::mit_encode(p_cmd, v_wire, c.kp, c.kd, t_ff, &m.ranges); + let arb = match m.vendor { + Vendor::MyActuator => proto::MA_MC_REQ + m.id as u16, + Vendor::Damiao => m.id as u16, + }; + (arb, frame) + } }; match guarded_send(&sock, arb, &frame, &mut enobufs_since)? { - SendOutcome::Sent => expected[motor_index] = true, + SendOutcome::Sent => expected[motor_index] = 1, SendOutcome::Dropped => {} SendOutcome::Stalled => { // The e-stop path: nothing has ACKed for >1 s. Stop @@ -2453,6 +2606,26 @@ fn bus_loop( } } + // 0xA4 joints: ask for the 0.01° multi-turn angle right behind the + // command so the reply pair lands inside this tick's window. + for (motor_index, m) in motors.iter().enumerate() { + if !a4_follow[motor_index] { + continue; + } + a4_follow[motor_index] = false; + if expected[motor_index] == 0 { + continue; + } + if let SendOutcome::Sent = guarded_send( + &sock, + proto::MA_REQ + m.id as u16, + &proto::MA_MULTI_TURN_REQUEST, + &mut enobufs_since, + )? { + expected[motor_index] = 2; + } + } + // Collect replies. The // window begins when this tick actually began, not at its nominal // schedule point: a late wake must not discard shoulder feedback @@ -2461,8 +2634,8 @@ fn bus_loop( // must end the window at `reply_deadline`, never a jiffy or two // later, or the overrun lands on the next tick as lateness. let reply_deadline = began + period.saturating_sub(REPLY_GUARD); - seen.fill(false); - let mut pending = expected.iter().filter(|&&value| value).count(); + seen.fill(0); + let mut pending: usize = expected.iter().map(|&n| n as usize).sum(); while pending > 0 { let now = Instant::now(); if now >= reply_deadline { @@ -2484,6 +2657,35 @@ fn bus_loop( ); (idx, pos, vel, tau) } + id if (0x241..=0x245).contains(&id) => { + // 0xA4 joints answer twice: the command echo (iq, + // speed, whole-degree angle) is staged; the 0x92 + // multi-turn read completes the sample. Torque is + // not available in Nm on this path (the echo carries + // q-axis current), so it is reported as NaN and the + // contact watchdog is blind on the joint. + let motor_id = (id - 0x240) as u8; + let Some(idx) = motors.iter().position(|m| m.id == motor_id) else { + continue; + }; + let slot = motors[idx].slot; + match frame.data[0] { + 0xA4 => { + let (iq, speed, _) = proto::ma_decode_a4_reply(&frame.data); + if mark_unique_expected_reply(&expected, &mut seen, idx) { + pending -= 1; + a4_stage[slot] = (speed, iq); + } + continue; + } + proto::MA_MULTI_TURN_ANGLE => { + let pos = proto::ma_decode_position(&frame.data); + let (vel, _iq) = a4_stage[slot]; + (idx, pos, vel, f64::NAN) + } + _ => continue, + } + } id if (0x16..=0x18).contains(&id) => { let motor_id = (id - 0x10) as u8; let Some(idx) = motors.iter().position(|m| m.id == motor_id) else { @@ -2566,9 +2768,10 @@ fn bus_loop( if motor.gripper { continue; } - feedback_fresh[motor.slot] = seen[idx]; + let complete = reply_complete(&expected, &seen, idx); + feedback_fresh[motor.slot] = complete; let health = &mut feedback_health[motor.slot]; - match health.record(seen[idx], silent_limit) { + match health.record(complete, silent_limit) { FeedbackVerdict::Steady => {} FeedbackVerdict::Degraded => { degraded_episodes += 1; diff --git a/scripts/creep_test.py b/scripts/creep_test.py new file mode 100644 index 00000000..9348f261 --- /dev/null +++ b/scripts/creep_test.py @@ -0,0 +1,378 @@ +"""Creep test: does a MyActuator joint move smoothly at slow speed under its +*own* position loop (0xA4 absolute position closed-loop), where the MIT +impedance frame stick-slips? + +Feasibility probe for moving the X8-P20 shoulders onto the firmware loop. +Streams a constant-velocity 0xA4 target at ``--rate`` Hz while reading the +fine multi-turn position (0x92, 0.01 deg/LSB) every cycle, then scores each +constant-speed segment: velocity ripple, lag, 1-4 Hz error band, and the +fraction of 50 ms windows in which the joint did not move at all (the +stick phases). No realtime core, no impedance control: the other joints +are parked in their firmware position holds exactly as the tuning probes +park them, and the arm is homed before and after. + +The comparable MIT numbers (right shoulder_1 at ~0.05 rad/s, extended, +2026-09-18): velocity swinging 0 to 0.15 rad/s at ~2 Hz, 1-4 Hz error band +0.05-0.11 deg, stairs 0.1-0.6 deg. + +Usage: + uv run python scripts/creep_test.py --r --joint shoulder_1 + uv run python scripts/creep_test.py --r --joint shoulder_1 --center -35 --amp 10 --speeds 2 3 6 --csv ~/creep-s1.csv + +The joint under test runs the whole time in firmware position control with +a speed cap (``--cap``, dps): it holds position stiffly and will push back +against contact up to the motor's limit. Keep the workspace clear. +""" + +from __future__ import annotations + +import argparse +import asyncio +import csv +import math +import struct +import time +from pathlib import Path + +import numpy as np + +from almond_axol.cli.motor import add_side_and_channel_arguments, resolve_channel +from almond_axol.cli.tune.friction import _home_all, _ramp_verified +from almond_axol.constants import ARM_JOINTS, Joint +from almond_axol.motor import CanBus, ControlMode, Motor +from almond_axol.motor.myactuator import MyActuatorMotor +from almond_axol.tuning import joint_frame_motors, safe_limits + +_MA_POS_CONTROL = 0xA4 +_MA_MULTI_TURN_ANGLE = 0x92 +_MA_READ_ACCEL = 0x42 +_MA_WRITE_ACCEL = 0x43 # RAM + ROM; 0x00 = position-plan accel, 0x01 = decel +#: The firmware needs a moment after a 0x43 flash write before it answers again. +_ACCEL_WRITE_SETTLE_S = 0.3 + + +async def _read_plan_accel(driver: MyActuatorMotor) -> tuple[int, int]: + """(acceleration, deceleration) of the position planner, dps/s.""" + out = [] + for kind in (0x00, 0x01): + resp = await driver._request(bytes([_MA_READ_ACCEL, kind, 0, 0, 0, 0, 0, 0])) + out.append(struct.unpack_from(" tuple[int, int]: + """Write the position planner's accel and decel (raw, so 0 = direct + tracking can be asked for) and return what the motor reads back.""" + for kind in (0x00, 0x01): + await driver._request( + bytes([_MA_WRITE_ACCEL, kind, 0, 0]) + struct.pack(" bytes: + """0xA4: uint16 speed cap (dps) + int32 target (0.01 deg).""" + return ( + bytes([_MA_POS_CONTROL, 0x00]) + + struct.pack(" tuple[int, float, float, float]: + """(temp C, iq A, speed rad/s, position rad) from a 0x240 control reply. + Position is the coarse int16 degrees the reply carries (1 deg/LSB).""" + temp = struct.unpack_from(" dict: + """Score one constant-velocity cruise (joint frame, rad).""" + if len(t) < 20: + return {} + dt = float(np.median(np.diff(t))) + err = pos - target + # Velocity from fine position, 50 ms box-averaged. + win = max(1, int(round(0.05 / dt))) + kern = np.ones(win) / win + v = np.convolve(np.gradient(pos, t), kern, mode="same") + core = slice(win, -win) if len(t) > 3 * win else slice(0, len(t)) + v_core = v[core] + # Stick fraction: windows with < 0.03 deg of travel while the command moves. + n_win = len(pos) // win + moved = np.abs( + np.diff(pos[: n_win * win].reshape(n_win, win)[:, [0, -1]], axis=1) + ).ravel() + stuck = float(np.mean(moved < math.radians(0.03))) if n_win else math.nan + expected = abs(v_cmd) * win * dt + stairs = float(np.degrees(np.max(moved) - expected)) if n_win else math.nan + e = err - err.mean() + n = len(e) + F = np.abs(np.fft.rfft(e * np.hanning(n))) ** 2 + f = np.fft.rfftfreq(n, dt) + band = math.sqrt(F[(f >= 1) & (f <= 4)].sum() / max(F.sum(), 1e-30)) * float( + e.std() + ) + return { + "v_cmd_dps": math.degrees(abs(v_cmd)), + "lag_ms": float(-err.mean() / v_cmd * 1000.0) if v_cmd else math.nan, + "err_rms_deg": float(np.degrees(err.std())), + "band_1_4_deg": float(np.degrees(band)), + "v_ripple": float(v_core.std() / max(abs(v_cmd), 1e-9)), + "v_min_frac": float(np.min(v_core * np.sign(v_cmd)) / max(abs(v_cmd), 1e-9)), + "stuck_frac": stuck, + "max_stair_deg": stairs, + } + + +async def _stream( + driver: MyActuatorMotor, + offset: float, + start: float, + end: float, + v: float, + cap_dps: float, + rate: float, + log: list, + seg: int, +) -> None: + """Stream a constant-velocity 0xA4 trajectory (joint frame) with a fine + position read every cycle.""" + period = 1.0 / rate + duration = abs(end - start) / v + sign = 1.0 if end > start else -1.0 + t0 = time.perf_counter() + deadline = t0 + while True: + t = time.perf_counter() - t0 + if t > duration + 0.5: + return + deadline += period + target = start + sign * v * min(t, duration) + resp = await driver._request(_a4_frame(target - offset, cap_dps)) + temp, iq, speed, pos_coarse = _decode_a4_reply(resp) + fine = await driver._request(bytes([_MA_MULTI_TURN_ANGLE, 0, 0, 0, 0, 0, 0, 0])) + pos = struct.unpack_from(" None: + joint = Joint(args.joint) + is_left = args.l + lo, hi = safe_limits(joint, is_left) + center = math.radians(args.center) + half = math.radians(args.amp) / 2.0 + margin = math.radians(3.0) + if not (lo + margin <= center - half and center + half <= hi - margin): + raise SystemExit( + f"range {args.center - args.amp / 2:+.1f}..{args.center + args.amp / 2:+.1f}° " + f"is outside the safe range [{math.degrees(lo) + 3:.1f}, {math.degrees(hi) - 3:.1f}]° " + f"for {joint.value}" + ) + speeds = [math.radians(s) for s in args.speeds] + channel = resolve_channel(args) + log: list = [] + print( + f"\nCreep test — {'left' if is_left else 'right'} {joint.value}: 0xA4 direct tracking" + ) + print( + f" range {args.center - args.amp / 2:+.1f}..{args.center + args.amp / 2:+.1f}°, " + f"speeds {args.speeds} deg/s, cap {args.cap:g} dps, {args.rate:g} Hz" + ) + async with CanBus(channel) as bus: + raw = {j: Motor(bus, j) for j in ARM_JOINTS} + await asyncio.gather(*[m.enable() for m in raw.values()]) + motors = await joint_frame_motors(raw, is_left) + await asyncio.gather( + *[ + m.set_control_mode(ControlMode.POSITION_VELOCITY) + for m in motors.values() + ] + ) + driver = motors[joint].motor._driver + if not isinstance(driver, MyActuatorMotor): + raise SystemExit(f"{joint.value} is not a MyActuator joint") + offset = motors[joint].offset + original_accel: tuple[int, int] | None = None + try: + acc, dec = await _read_plan_accel(driver) + print(f" position planner: accel {acc} dps/s, decel {dec} dps/s (stored)") + if args.accel is not None and (acc, dec) != (args.accel, args.accel): + original_accel = (acc, dec) + got = await _write_plan_accel(driver, args.accel) + mode = "direct PI tracking" if got[0] == 0 else "velocity-profiled hops" + print( + f" position planner now: accel {got[0]}, decel {got[1]} dps/s → {mode}" + ) + if got[0] != args.accel: + print( + f" ! the motor did not accept {args.accel}; running with {got[0]}" + ) + elif acc == 0: + print(" mode: direct PI tracking") + else: + print(" mode: velocity-profiled hops between streamed targets") + print(" Homing all joints to rest ...") + await _home_all(motors) + start = center - half + print(f" Ramping {joint.value} to {math.degrees(start):+.1f}° ...") + await _ramp_verified(motors, {joint: start}) + await asyncio.sleep(0.5) + seg = 0 + here = start + for v in speeds: + for _ in range(args.passes): + there = center + half if here < center else center - half + print( + f" segment {seg}: {math.degrees(here):+.1f} → {math.degrees(there):+.1f}° at {math.degrees(v):.1f} deg/s" + ) + await _stream( + driver, offset, here, there, v, args.cap, args.rate, log, seg + ) + here = there + seg += 1 + except KeyboardInterrupt: + print("\n Interrupted.") + finally: + print(" Returning to rest and disabling ...") + try: + await _ramp_verified(motors, {joint: 0.0}) + await _home_all(motors) + except Exception: # noqa: BLE001 - best-effort teardown + pass + if original_accel is not None: + try: + got = await _write_plan_accel(driver, original_accel[0]) + print( + f" position planner restored: accel {got[0]}, decel {got[1]} dps/s" + ) + except Exception: # noqa: BLE001 - report, the value is in ROM + print( + f" ! could not restore the planner acceleration ({original_accel[0]} " + "dps/s) — set it with `axol motor.set-config` before the next session" + ) + await asyncio.gather( + *[m.set_control_mode(ControlMode.IMPEDANCE) for m in motors.values()] + ) + await asyncio.gather(*[m.disable() for m in raw.values()]) + + if not log: + print("No samples.") + return + arr = np.array(log, dtype=float) + if args.csv is not None: + with open(args.csv, "w", newline="") as f: + w = csv.writer(f) + w.writerow( + [ + "t", + "seg", + "v_cmd", + "target", + "pos_fine", + "pos_coarse", + "speed_motor", + "iq_a", + "temp_c", + ] + ) + w.writerows(arr.tolist()) + print(f" samples → {args.csv}") + print( + f"\n{'seg':>3s} {'v dps':>6s} {'lag ms':>7s} {'err°':>6s} {'band1-4°':>8s} {'v ripple':>8s} {'v min':>6s} {'stuck':>6s} {'stair°':>7s}" + ) + for seg in np.unique(arr[:, 1]).astype(int): + s = (arr[:, 1] == seg) & (arr[:, 2] != 0.0) + t = arr[s, 0] + if len(t) < 20: + continue + cruise = t > t[0] + 0.3 + m = segment_metrics( + t[cruise], arr[s, 3][cruise], arr[s, 4][cruise], float(arr[s, 2][0]) + ) + if m: + print( + f"{seg:3d} {m['v_cmd_dps']:6.1f} {m['lag_ms']:7.1f} {m['err_rms_deg']:6.3f} " + f"{m['band_1_4_deg']:8.3f} {m['v_ripple']:8.2f} {m['v_min_frac']:6.2f} " + f"{m['stuck_frac']:6.2f} {m['max_stair_deg']:7.3f}" + ) + print( + "\n v ripple = std(velocity)/|v_cmd| (MIT stick-slip ≈ 1.0, smooth < 0.2); " + "v min = slowest 50 ms window over |v_cmd| (0 = it stopped);\n" + " stuck = fraction of 50 ms windows with < 0.03° travel; stair = largest window travel beyond the commanded amount." + ) + + +def main() -> None: + p = argparse.ArgumentParser( + description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter + ) + add_side_and_channel_arguments(p) + p.add_argument( + "--joint", default="shoulder_1", choices=[j.value for j in ARM_JOINTS] + ) + p.add_argument( + "--center", + type=float, + default=-35.0, + help="Centre of the creep range, joint-frame degrees (default: -35)", + ) + p.add_argument( + "--amp", + type=float, + default=10.0, + help="Total travel per pass, degrees (default: 10)", + ) + p.add_argument( + "--speeds", + type=float, + nargs="+", + default=[2.0, 3.0, 6.0], + help="Creep speeds, deg/s (default: 2 3 6)", + ) + p.add_argument( + "--passes", + type=int, + default=2, + help="Passes per speed, alternating direction (default: 2)", + ) + p.add_argument( + "--cap", type=float, default=45.0, help="0xA4 speed cap, dps (default: 45)" + ) + p.add_argument( + "--rate", type=float, default=200.0, help="Command rate, Hz (default: 200)" + ) + p.add_argument( + "--accel", + type=int, + default=None, + help="Position-planner acceleration (dps/s) to run with: 0 asks for direct PI " + "tracking, otherwise velocity-profiled hops. Written to the motor for the run " + "and restored afterwards (default: leave the stored value)", + ) + p.add_argument("--csv", type=Path, default=None, help="Write every sample here") + args = p.parse_args() + asyncio.run(_run(args)) + + +if __name__ == "__main__": + main() diff --git a/scripts/fw_gains.py b/scripts/fw_gains.py new file mode 100644 index 00000000..a430b449 --- /dev/null +++ b/scripts/fw_gains.py @@ -0,0 +1,103 @@ +"""Read or set a MyActuator joint's firmware loop gains, in RAM by default. + +The X8-P20 shoulders ship with a position loop of ~0.3 Hz bandwidth +(position_kp 0.008) and a speed loop (speed_kp 0.03, speed_ki 0.0001) that +cannot hold creep speed against the gearbox's velocity-weakening friction — +an 0xA4 replay of `slow_osc` tracked with a 4-7° lag and the same 2 Hz +velocity cycle the MIT frame shows. This is the sweep tool for that: write a +few gains, replay, read the trace, repeat. + +Writes go to RAM (0x31) unless ``--persist`` is given (0x32, ROM), so an +experiment is undone by a power cycle and a good set is committed +deliberately. Every write is read back (0x30) and printed. + +Usage: + uv run python scripts/fw_gains.py --r --id 1 # read + uv run python scripts/fw_gains.py --r --id 1 speed_kp=0.1 speed_ki=0.001 + uv run python scripts/fw_gains.py --r --id 1 position_kp=0.05 --persist + +Gains: current_kp current_ki speed_kp speed_ki position_kp position_ki position_kd +""" + +from __future__ import annotations + +import argparse +import asyncio +import struct + +from almond_axol.cli.motor import add_side_and_channel_arguments, resolve_channel +from almond_axol.motor.bus import CanBus +from almond_axol.motor.motor import make_driver +from almond_axol.motor.myactuator import _MA_PID_IDX, MyActuatorMotor + +_READ = 0x30 +_WRITE_RAM = 0x31 +_WRITE_ROM = 0x32 + + +async def _read(driver: MyActuatorMotor, name: str) -> float: + resp = await driver._request(bytes([_READ, _MA_PID_IDX[name], 0, 0, 0, 0, 0, 0])) + return float(struct.unpack_from(" None: + cmd = _WRITE_ROM if persist else _WRITE_RAM + await driver._request( + bytes([cmd, _MA_PID_IDX[name], 0, 0]) + struct.pack(" None: + writes: dict[str, float] = {} + for spec in args.gains: + name, _, raw = spec.partition("=") + if name not in _MA_PID_IDX or not raw: + raise SystemExit( + f"bad gain {spec!r}; want NAME=VALUE with NAME in {', '.join(_MA_PID_IDX)}" + ) + writes[name] = float(raw) + async with CanBus(resolve_channel(args)) as bus: + driver = make_driver(bus, args.id, kt=1.0) + if not isinstance(driver, MyActuatorMotor): + raise SystemExit(f"motor {args.id:#04x} is not a MyActuator") + before = {n: await _read(driver, n) for n in _MA_PID_IDX} + print(f"motor {args.id:#04x} gains now:") + for n, v in before.items(): + print(f" {n:12s} {v:.6g}") + if not writes: + return + for n, v in writes.items(): + await _write(driver, n, v, args.persist) + after = {n: await _read(driver, n) for n in _MA_PID_IDX} + print( + f"\nafter writing to {'ROM (persistent)' if args.persist else 'RAM (until power cycle)'}:" + ) + for n in writes: + flag = ( + "" + if abs(after[n] - writes[n]) < 1e-6 * max(1.0, abs(writes[n])) + else " ! not accepted" + ) + print(f" {n:12s} {before[n]:.6g} -> {after[n]:.6g}{flag}") + + +def main() -> None: + p = argparse.ArgumentParser( + description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter + ) + add_side_and_channel_arguments(p) + p.add_argument("--id", type=lambda x: int(x, 0), required=True, help="Motor CAN ID") + p.add_argument("gains", nargs="*", metavar="NAME=VALUE", help="Gains to write") + p.add_argument( + "--persist", + action="store_true", + help="Write to ROM (0x32) instead of RAM (0x31); survives a power cycle", + ) + asyncio.run(_run(p.parse_args())) + + +if __name__ == "__main__": + main() diff --git a/tests/test_command_sections.py b/tests/test_command_sections.py index ac35b881..9fac3721 100644 --- a/tests/test_command_sections.py +++ b/tests/test_command_sections.py @@ -14,6 +14,7 @@ # dashboard renders no other grid for them. Keep this list honest. _CLI_ONLY_TUNING = {"tune.filter"} _WORKBENCH_TABS = { + "tune.a4", "tune.pid", "tune.friction", "tune.gravity", diff --git a/tests/test_rom_partial_arm.py b/tests/test_rom_partial_arm.py index 35139208..0241ce2c 100644 --- a/tests/test_rom_partial_arm.py +++ b/tests/test_rom_partial_arm.py @@ -295,7 +295,9 @@ def test_config_lists_only_present_motors(self) -> None: # Slot-by-motor-id is protocol generation 2; a core that predates it # would slot these wrists at 0 and 1 and then reject every target, # so the config declares the generation and such a core refuses it. - self.assertEqual(lines[0], "proto 2") + from almond_axol.rt.link import CONFIG_PROTO + + self.assertEqual(lines[0], f"proto {CONFIG_PROTO}") joint_lines = [line for line in lines if line.startswith("joint ")] self.assertEqual( [line.split()[3:5] for line in joint_lines], @@ -395,7 +397,14 @@ async def test_bench_arm_streams_soft_pd_and_zero_feedforward(self) -> None: rt = Axol._wrap(axol) for line in rt._config_text().splitlines(): if line.startswith("joint "): - self.assertEqual(line.split()[9:], ["0.0", "0.0", "0.0", "0.0"], line) + self.assertEqual(line.split()[9:13], ["0.0", "0.0", "0.0", "0.0"], line) + # Stiction and dither terms stay off on the bench too, and + # every joint is on the MIT frame. + self.assertEqual( + line.split()[13:], + ["0.0", "0.0017453292519943296", "0.0", "0.0", "60.0", "mit"], + line, + ) def test_only_a_partial_arm_is_a_bench_run(self) -> None: """A full arm on the bus — even with a joint subset selected — is the diff --git a/tests/test_rt_link.py b/tests/test_rt_link.py index ea574211..d60979e3 100644 --- a/tests/test_rt_link.py +++ b/tests/test_rt_link.py @@ -99,7 +99,10 @@ def _link(self, proc) -> link.RtLink: def test_config_header_declares_the_protocol(self) -> None: self.assertEqual(link.config_header(), [f"proto {link.CONFIG_PROTO}"]) - self.assertEqual(link.CONFIG_PROTO, 2) + # 2: slot-by-motor-id; 3/4: stiction fields; 5: dither fields; 6: wire + # mode token on every joint line. Bump both sides together + # (rust/axol-rt/src/serve.rs CONFIG_PROTO). + self.assertEqual(link.CONFIG_PROTO, 6) async def test_configure_names_a_stale_binary_when_the_core_exits(self) -> None: rt = self._link(_ExitedProc()) @@ -108,7 +111,7 @@ async def test_configure_names_a_stale_binary_when_the_core_exits(self) -> None: await rt.configure("proto 2\nloop_hz 240\n") message = str(ctx.exception) self.assertIn("/opt/axol-rt", message) - self.assertIn("proto 2", message) + self.assertIn(f"proto {link.CONFIG_PROTO}", message) self.assertIn("axol rt.install", message) # The exit is noticed in well under the 5 s ack timeout. self.assertLess(asyncio.get_running_loop().time() - started, 2.0) diff --git a/tests/test_rt_mantis.py b/tests/test_rt_mantis.py index 9c8e503d..e065c7e1 100644 --- a/tests/test_rt_mantis.py +++ b/tests/test_rt_mantis.py @@ -211,7 +211,9 @@ async def test_enable_grippers_hands_the_buses_to_the_core(self) -> None: (link,) = _FakeLink.instances config_lines = link.config.splitlines() # The protocol declaration leads every config (see RtLink.configure). - self.assertEqual(config_lines[0], "proto 2") + from almond_axol.rt.link import CONFIG_PROTO + + self.assertEqual(config_lines[0], f"proto {CONFIG_PROTO}") self.assertEqual( config_lines[4:], ["gripper 0 can_mantis_l 8", "gripper 1 can_mantis_r 8"], diff --git a/tests/test_stiction.py b/tests/test_stiction.py new file mode 100644 index 00000000..d6c115f5 --- /dev/null +++ b/tests/test_stiction.py @@ -0,0 +1,156 @@ +"""Stiction compensation: the control math, its config plumbing, and the +realtime-core config line that carries it. + +The Rust core pins ``filter::stiction`` / ``filter::coulomb_unit`` to the +vectors in ``stiction_matches_python`` (rust/axol-rt/src/filter.rs); the +golden table here is the same one, so a change to either side breaks both. +""" + +from __future__ import annotations + +import math +import unittest + +from almond_axol.robot.config import ( + ArmConfig, + AxolConfig, + JointConfig, + _calibrated_joint, +) +from almond_axol.robot.control import ( + DITHER_PHASE_STAGGER, + STICTION_FADE_VEL, + TorqueDither, + compute_friction, + coulomb_unit, + dither_step, + stiction_amplitude, + stiction_compensation, +) + +_SCALE = math.radians(0.1) + +# (err rad, v_meas rad/s, want Nm) for amp = 0.36 (fc = 0.6, gain = 0.6). +_GOLDEN = [ + (-0.02, 0.0, -0.3599999999198255), + (-0.002, 0.0, -0.29390273095808195), + (-0.0005, 0.0, -0.10040068111546155), + (0.0, 0.0, 0.0), + (0.0005, 0.0, 0.10040068111546155), + (0.002, 0.0, 0.29390273095808195), + (0.02, 0.0, 0.3599999999198255), + (0.02, 0.022, 0.1349638509268763), + (0.02, -0.044, 0.03638171680139523), + (0.02, 0.15, 3.268646545848154e-05), +] + +# Dither at 1.5 Nm / 60 Hz stepped at 240 Hz: first four outputs of slot 0 +# and slot 1 (one golden angle further round). +_DITHER_GOLDEN = { + 0: [1.5, 8.498308346471969e-16, -1.5, -1.6996616692943939e-15], + 1: [ + -1.1060533171174791, + -1.0132354413922864, + 1.106053317117479, + 1.0132354413922875, + ], +} + + +class StictionMathTest(unittest.TestCase): + def test_golden_vectors_shared_with_the_core(self) -> None: + for err, v, want in _GOLDEN: + amp = stiction_amplitude(0.6, 0.6, 0.0, 0.0) + got = stiction_compensation(err, v, amp, _SCALE) + self.assertAlmostEqual(got, want, places=12, msg=f"err={err} v={v}") + + def test_dither_golden_vectors_shared_with_the_core(self) -> None: + self.assertAlmostEqual(DITHER_PHASE_STAGGER, 2.399963229728653, places=15) + for slot, want in _DITHER_GOLDEN.items(): + phase = slot * DITHER_PHASE_STAGGER + for k, w in enumerate(want): + phase, torque = dither_step(phase, 1.5, 60.0, 1.0 / 240.0) + self.assertAlmostEqual(torque, w, places=12, msg=f"slot {slot} k {k}") + # Off is exactly off, and does not run the phase. + self.assertEqual(dither_step(1.0, 0.0, 60.0, 0.01), (1.0, 0.0)) + dither = TorqueDither(2) + self.assertEqual(dither.update([0.0, 0.0], [60.0, 60.0]), [0.0, 0.0]) + + def test_zero_gain_is_exactly_the_production_law(self) -> None: + self.assertEqual(stiction_amplitude(0.6, 0.0, 0.0, 12.0), 0.0) + self.assertEqual(stiction_compensation(0.02, 0.0, 0.0, _SCALE), 0.0) + + def test_amplitude_follows_the_gravity_load(self) -> None: + # right shoulder_1: 0.39 Nm at rest, ~2.8 Nm under 12 Nm of gravity. + self.assertAlmostEqual(stiction_amplitude(1.3, 0.3, 0.2, 0.0), 0.39) + self.assertAlmostEqual(stiction_amplitude(1.3, 0.3, 0.2, -12.0), 2.79) + self.assertAlmostEqual(stiction_amplitude(1.3, 0.3, 0.2, 12.0), 2.79) + + def test_pushes_toward_the_target_and_saturates_at_gain_fc(self) -> None: + amp = stiction_amplitude(1.3, 0.6, 0.0, 0.0) + big = stiction_compensation(0.1, 0.0, amp, _SCALE) + self.assertAlmostEqual(big, 0.6 * 1.3, places=6) + self.assertLess(stiction_compensation(-0.1, 0.0, amp, _SCALE), 0.0) + self.assertEqual(stiction_compensation(0.1, 0.0, 0.0, _SCALE), 0.0) + # Within the scale the push is proportional: a stiffer-than-kp spring. + small = stiction_compensation(_SCALE / 10, 0.0, amp, _SCALE) + self.assertAlmostEqual(small, 0.6 * 1.3 * math.tanh(0.1), places=9) + + def test_fades_on_measured_velocity_not_commanded(self) -> None: + # The push is for a *stuck* joint: full at rest, gone once the joint + # actually slides — whatever the command is doing. + amp = stiction_amplitude(1.3, 0.6, 0.0, 0.0) + stuck = stiction_compensation(0.02, 0.0, amp, _SCALE) + one_lsb = stiction_compensation(0.02, 0.022, amp, _SCALE) + sliding = stiction_compensation(0.02, 0.15, amp, _SCALE) + self.assertAlmostEqual(stuck, 0.78, places=6) + self.assertLess(one_lsb, 0.5 * stuck) + self.assertLess(sliding, 0.001) + self.assertEqual(STICTION_FADE_VEL, 0.03) + total = compute_friction(0.3, 1.3, 250.0, 0.0, 0.0) + sliding + self.assertLessEqual(total, (1 + 0.6) * 1.3 + 1e-9) + + def test_coulomb_unit_matches_compute_friction(self) -> None: + for v in (-1.0, -0.05, 0.0, 0.02, 0.4): + self.assertAlmostEqual( + compute_friction(v, 0.6, 250.0, 0.15, 0.02), + 0.6 * coulomb_unit(v, 250.0) + 0.15 * v + 0.02, + places=12, + ) + + +class StictionConfigTest(unittest.TestCase): + def test_defaults_are_off_on_every_joint(self) -> None: + arm = ArmConfig() + for name in ( + "shoulder_1", + "shoulder_2", + "shoulder_3", + "elbow", + "wrist_1", + "wrist_2", + "wrist_3", + ): + jc: JointConfig = getattr(arm, name) + self.assertEqual(jc.stiction_gain, 0.0, name) + self.assertEqual(jc.stiction_err_deg, 0.1, name) + self.assertEqual(jc.dither_nm, 0.0, name) + self.assertEqual(jc.wire_mode, "mit", name) + + def test_calibration_file_can_set_the_fields(self) -> None: + base = ArmConfig().shoulder_1 + out = _calibrated_joint(base, {"stiction_gain": 0.5, "stiction_err_deg": 0.2}) + self.assertEqual((out.stiction_gain, out.stiction_err_deg), (0.5, 0.2)) + untouched = _calibrated_joint(base, {"kp": 200.0}) + self.assertEqual(untouched.stiction_gain, 0.0) + + def test_resolved_config_keeps_the_fields(self) -> None: + cfg = AxolConfig() + cfg.right.shoulder_1.stiction_gain = 0.6 + resolved = cfg.resolved() + self.assertEqual(resolved.right.shoulder_1.stiction_gain, 0.6) + self.assertEqual(resolved.left.shoulder_1.stiction_gain, 0.0) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_tune_a4.py b/tests/test_tune_a4.py new file mode 100644 index 00000000..834355e4 --- /dev/null +++ b/tests/test_tune_a4.py @@ -0,0 +1,138 @@ +"""The pure parts of ``axol tune.a4``: the wave generator, the buzz guard, the +0xA4 frame, and the creep-smoothness scorecard.""" + +from __future__ import annotations + +import math +import unittest + +import numpy as np + +from almond_axol.cli.tune import a4 + + +class WaveformTest(unittest.TestCase): + def test_sine_starts_at_centre_with_matching_velocity(self) -> None: + w = a4.waveform("sine", 0.5, 0.1, 2.0, 100.0, freq=0.5) + self.assertEqual(len(w), 200) + t0, q0, v0 = w[0] + self.assertEqual((t0, q0), (0.0, 0.5)) + self.assertAlmostEqual(v0, 0.1 * 2 * math.pi * 0.5) + # Quarter period later the sine peaks and the velocity is zero. + _, q, v = w[50] + self.assertAlmostEqual(q, 0.6, places=6) + self.assertAlmostEqual(v, 0.0, places=6) + + def test_triangle_runs_every_leg_at_the_set_speed(self) -> None: + w = a4.waveform("triangle", 0.0, 0.2, 20.0, 100.0, speed=0.1) + q = np.array([s[1] for s in w]) + v = np.array([s[2] for s in w]) + self.assertAlmostEqual(q.max(), 0.2, places=6) + self.assertAlmostEqual(q.min(), -0.2, places=6) + # Velocity is ±speed everywhere, and the position slope matches it on + # every sample except the turnarounds. + self.assertTrue(np.allclose(np.abs(v), 0.1)) + slope = np.abs(np.diff(q) * 100.0) + self.assertGreater(np.mean(np.isclose(slope, 0.1, atol=1e-6)), 0.95) + # First leg goes up from the centre at +speed. + self.assertEqual(w[0][1], 0.0) + self.assertGreater(w[1][1], 0.0) + + def test_zero_speed_or_frequency_holds(self) -> None: + self.assertTrue( + all( + q == 1.0 and v == 0.0 + for _, q, v in a4.waveform("triangle", 1.0, 0.2, 1.0, 50.0, speed=0.0) + ) + ) + self.assertTrue( + all( + q == 1.0 + for _, q, _v in a4.waveform("sine", 1.0, 0.2, 1.0, 50.0, freq=0.0) + ) + ) + with self.assertRaises(ValueError): + a4.waveform("square", 0.0, 0.1, 1.0, 50.0) + + +class BuzzGuardTest(unittest.TestCase): + def test_smooth_creep_passes_and_vibration_trips(self) -> None: + guard = a4.BuzzGuard(200.0, math.radians(0.3), 10.0) + # A steady 3 deg/s creep never trips. + for k in range(60): + self.assertIsNone(guard.feed(math.radians(3.0) * k / 200.0, 1.0)) + # A 25 Hz, 0.6° oscillation does within one window. + tripped = None + for k in range(60, 120): + tripped = guard.feed( + math.radians(0.9) + + math.radians(0.6) * math.sin(2 * math.pi * 25 * k / 200.0), + 1.0, + ) + if tripped: + break + self.assertIsNotNone(tripped) + self.assertIn("high-frequency", tripped) + + def test_current_limit_trips_immediately(self) -> None: + guard = a4.BuzzGuard(200.0, math.radians(0.3), 5.0) + self.assertIsNone(guard.feed(0.0, 4.0)) + self.assertIn("current", guard.feed(0.0, -6.0) or "") + + def test_limits_at_zero_are_off(self) -> None: + guard = a4.BuzzGuard(200.0, 0.0, 0.0) + for k in range(100): + self.assertIsNone(guard.feed(math.sin(k), 50.0)) + + +class FrameTest(unittest.TestCase): + def test_a4_frame_matches_the_vendor_example(self) -> None: + self.assertEqual( + a4._a4_frame(2 * math.pi, 500.0), + bytes([0xA4, 0x00, 0xF4, 0x01, 0xA0, 0x8C, 0x00, 0x00]), + ) + iq, speed = a4._decode_a4_reply( + bytes([0xA4, 0x32, 0x64, 0x00, 0xF4, 0x01, 0x2D, 0x00]) + ) + self.assertAlmostEqual(iq, 1.0) + self.assertAlmostEqual(speed, math.radians(500.0)) + + +class MetricsTest(unittest.TestCase): + def _log(self, lag_s: float, ripple: float) -> list[dict]: + rate = 200.0 + rng = np.random.default_rng(1) + rows = [] + for k in range(int(10 * rate)): + t = k / rate + target = 0.05 * t + actual = 0.05 * (t - lag_s) + ripple * math.sin(2 * math.pi * 2.0 * t) + rows.append( + { + "t": t, + "target": target, + "actual": actual + rng.normal(0, 1e-5), + "error": actual - target, + "torque": math.nan, + "speed": 0.05, + "iq": 1.5, + "v_cmd": 0.05, + } + ) + return rows + + def test_scores_lag_and_creep_smoothness(self) -> None: + smooth = a4.a4_metrics(self._log(0.1, 0.0), 200.0) + self.assertAlmostEqual(smooth["lag_ms"], 100.0, delta=6.0) + self.assertLess(smooth["v_ripple"], 0.05) + self.assertEqual(smooth["stuck_frac"], 0.0) + self.assertLess(math.degrees(smooth["band_1_4"]), 0.01) + self.assertAlmostEqual(smooth["iq_rms"], 1.5) + # A 2 Hz ±0.3° wobble on the same creep shows up in the band and ripple. + wobbly = a4.a4_metrics(self._log(0.1, math.radians(0.3)), 200.0) + self.assertGreater(math.degrees(wobbly["band_1_4"]), 0.15) + self.assertGreater(wobbly["v_ripple"], 0.5) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_tune_breakaway.py b/tests/test_tune_breakaway.py new file mode 100644 index 00000000..8303faf4 --- /dev/null +++ b/tests/test_tune_breakaway.py @@ -0,0 +1,99 @@ +"""The pure parts of ``axol tune.breakaway``: the escalation schedule, the +ramp shape, the trim search, the pose clamping, and the friction/bias split +the hardware loop feeds its measurements into.""" + +from __future__ import annotations + +import math +import unittest + +from almond_axol.cli.tune import breakaway as ba +from almond_axol.constants import Joint + + +class ScheduleTest(unittest.TestCase): + def test_peaks_scale_with_fc_and_stop_at_the_cap(self) -> None: + peaks = ba.peak_schedule(1.3, 3.0) + self.assertEqual(peaks[0], 0.5 * 1.3) + self.assertEqual(peaks[-1], 3.0 * 1.3) + self.assertEqual(peaks, sorted(peaks)) + self.assertTrue(all(p <= 3.0 * 1.3 + 1e-9 for p in peaks)) + + def test_low_friction_joints_get_the_floor(self) -> None: + # A Damiao wrist with fc = 0.1 Nm would otherwise ramp to 0.05 Nm. + peaks = ba.peak_schedule(0.1, 2.0) + self.assertEqual(peaks[0], 0.5 * ba._FC_FLOOR_NM) + self.assertEqual(peaks[-1], 2.0 * ba._FC_FLOOR_NM) + + def test_triangle_rises_to_one_and_returns_to_zero(self) -> None: + self.assertEqual(ba.triangle(0.0, 4.0), 0.0) + self.assertAlmostEqual(ba.triangle(1.0, 4.0), 0.5) + self.assertAlmostEqual(ba.triangle(2.0, 4.0), 1.0) + self.assertAlmostEqual(ba.triangle(3.0, 4.0), 0.5) + self.assertEqual(ba.triangle(4.0, 4.0), 0.0) + self.assertEqual(ba.triangle(5.0, 4.0), 0.0) + + +class SplitTest(unittest.TestCase): + def test_symmetric_releases_are_pure_friction(self) -> None: + f_static, bias = ba.split_breakaway([3.0, 3.1], [2.9, 3.0]) + self.assertAlmostEqual(f_static, 3.0) + self.assertAlmostEqual(bias, -0.05) + + def test_a_feedforward_residual_shows_up_as_bias(self) -> None: + # Hold torque over-supplies +0.4 Nm: the + direction releases 0.4 + # early, the - direction 0.4 late. + f_static, bias = ba.split_breakaway([2.6], [3.4]) + self.assertAlmostEqual(f_static, 3.0) + self.assertAlmostEqual(bias, 0.4) + + def test_predicted_stair_is_the_uncovered_torque_over_kp(self) -> None: + # 3.0 Nm breakaway, 1.3 Nm fc, kp 250: (1.7 / 250) rad ≈ 0.39°. + self.assertAlmostEqual( + ba.predicted_stair_deg(3.0, 1.3, 250.0), 0.3896, places=3 + ) + self.assertEqual(ba.predicted_stair_deg(1.0, 1.3, 250.0), 0.0) + self.assertTrue(math.isnan(ba.predicted_stair_deg(3.0, 1.3, 0.0))) + + +class TrimSearchTest(unittest.TestCase): + def test_converges_on_a_standing_hold(self) -> None: + search = ba.TrimSearch(step=0.2, max_trim=4.0) + # The joint drifts + (torque too high) until the trim comes down. + self.assertAlmostEqual(search.update(math.radians(0.5)), -0.2) + self.assertAlmostEqual(search.update(math.radians(0.3)), -0.4) + # Overshoot: drift flips sign, so the step halves. + self.assertAlmostEqual(search.update(math.radians(-0.2)), -0.3) + self.assertFalse(search.done) + search.update(0.0) + self.assertTrue(search.done) + self.assertFalse(search.failed) + + def test_gives_up_when_the_trim_runs_away(self) -> None: + search = ba.TrimSearch(step=1.0, max_trim=2.5) + for _ in range(3): + search.update(math.radians(1.0)) + self.assertTrue(search.failed) + + +class PosesTest(unittest.TestCase): + def test_default_pose_is_rest(self) -> None: + self.assertEqual(ba._probe_poses(Joint.SHOULDER_1, False, None), [0.0]) + + def test_base_collision_joint_is_pushed_outboard(self) -> None: + # Right shoulder_2's outboard side is +; the rest pose sits on the + # boundary, so the probe moves 5° out where a release cannot cross it. + (pose,) = ba._probe_poses(Joint.SHOULDER_2, False, [0.0]) + self.assertAlmostEqual(pose, ba._BOUNDARY_MARGIN) + (pose_l,) = ba._probe_poses(Joint.SHOULDER_2, True, [0.0]) + self.assertAlmostEqual(pose_l, -ba._BOUNDARY_MARGIN) + + def test_poses_are_kept_inside_the_safe_range(self) -> None: + poses = ba._probe_poses(Joint.SHOULDER_1, False, [-720.0, 720.0]) + lo, hi = ba.safe_limits(Joint.SHOULDER_1, False) + self.assertGreater(poses[0], lo) + self.assertLess(poses[1], hi) + + +if __name__ == "__main__": + unittest.main() diff --git a/web/app/src/components/diagnostics/tuning-workbench.tsx b/web/app/src/components/diagnostics/tuning-workbench.tsx index 6383a069..d01c4d99 100644 --- a/web/app/src/components/diagnostics/tuning-workbench.tsx +++ b/web/app/src/components/diagnostics/tuning-workbench.tsx @@ -250,6 +250,65 @@ const TABS: WbTab[] = [ required: ["arm", "joint"], drivesMotors: true, }, + { + key: "a4", + label: "Firmware loop", + command: "tune.a4", + description: + "Tune a MyActuator joint's own position loop (0xA4, the controller " + + "behind wire_mode a4) with a sine or a constant-speed triangle. Firmware " + + "gains are written to RAM for the run and restored afterwards (persist " + + "writes ROM, keep leaves them); planner acceleration must be 0 for the " + + "joint to follow a stream. A buzz guard restores the previous gains on " + + "any high-frequency motion. Compare runs on velocity ripple (MIT " + + "stick-slip ≈ 0.8, smooth < 0.2), stuck windows, lag and the 1–4 Hz band. " + + "The joint holds stiffly and does not yield to a hand: clear the space.", + presets: { save_run: true }, + fields: [ + { key: "arm", label: "arm", type: "select", options: ["left", "right"] }, + { + key: "joint", + label: "joint", + type: "select", + options: ["shoulder_1", "shoulder_2", "shoulder_3", "elbow", "wrist_1"], + }, + { key: "mode", label: "wave", type: "select", options: ["triangle", "sine"] }, + { + key: "center", + label: "center (°)", + type: "number", + placeholder: "mid", + hint: "joint-frame centre (0 = rest); probe under gravity load, e.g. -35 on shoulder_1", + }, + { key: "amp", label: "half-travel (°)", type: "number", placeholder: "10" }, + { key: "speed", label: "triangle speed (°/s)", type: "number", placeholder: "3" }, + { key: "freq", label: "sine freq (Hz)", type: "number", placeholder: "0.3" }, + { key: "duration", label: "duration (s)", type: "number", placeholder: "12" }, + { key: "rate", label: "rate (Hz)", type: "number", placeholder: "200" }, + { key: "cap", label: "speed cap (°/s)", type: "number", placeholder: "60" }, + { + key: "accel", + label: "planner accel (dps/s)", + type: "number", + placeholder: "stored", + hint: "0 = direct PI tracking (required to follow the stream); restored after the run unless kept", + }, + { key: "position_kp", label: "position_kp", type: "number", placeholder: "stock", hint: "X8 shoulders ship 0.008" }, + { key: "position_ki", label: "position_ki", type: "number", placeholder: "stock" }, + { key: "position_kd", label: "position_kd", type: "number", placeholder: "stock" }, + { key: "speed_kp", label: "speed_kp", type: "number", placeholder: "stock", hint: "X8 shoulders ship 0.03; 0.1 vibrated — step in small increments" }, + { key: "speed_ki", label: "speed_ki", type: "number", placeholder: "stock" }, + { key: "current_kp", label: "current_kp", type: "number", placeholder: "stock" }, + { key: "current_ki", label: "current_ki", type: "number", placeholder: "stock" }, + { key: "buzz_abort", label: "buzz abort (°)", type: "number", placeholder: "0.3" }, + { key: "iq_abort", label: "current abort (A)", type: "number", placeholder: "10" }, + { key: "persist", label: "persist gains to ROM", type: "boolean" }, + { key: "keep", label: "keep gains + planner after run", type: "boolean" }, + { key: "label", label: "label", type: "text", placeholder: "note", width: "w-40" }, + ], + required: ["arm", "joint"], + drivesMotors: true, + }, { key: "motion", label: "Recorded motion", From cfaf58804f55958d53610d28f5b2ca47d5a9e07a Mon Sep 17 00:00:00 2001 From: Shawn Patel Date: Fri, 18 Sep 2026 17:13:28 -0700 Subject: [PATCH 02/80] Firmware-loop tab: show the motor's live loop gains as each field's baseline The seven firmware gain fields on the tune.a4 workbench tab read "stock", which says nothing about where to move from. The serve API already returns a motor's live loop gains in its motor.info readout, so the tab now fetches them whenever arm and joint are picked (and again after a run ends), shows "motor 0.008" next to each label, seeds a tightly ranged slider there, and uses the value as the box placeholder. Gains print at four significant digits so 0.0001 does not render as 0.00. Co-Authored-By: Claude Fable 5.1 --- docs/cli/tune-a4.mdx | 2 +- .../diagnostics/tuning-workbench.tsx | 129 ++++++++++++++++-- 2 files changed, 120 insertions(+), 11 deletions(-) diff --git a/docs/cli/tune-a4.mdx b/docs/cli/tune-a4.mdx index 0f35ad5a..e08da1a9 100644 --- a/docs/cli/tune-a4.mdx +++ b/docs/cli/tune-a4.mdx @@ -29,7 +29,7 @@ Safety, built in: | `--rate HZ` | Command rate (default: 200) | | `--cap DPS` | 0xA4 speed cap (default: 60) | | `--accel DPS/S` | Planner acceleration for the run; `0` = direct tracking. Written to ROM, restored afterwards unless `--keep` | -| `--position-kp`, `--position-ki`, `--position-kd`, `--speed-kp`, `--speed-ki`, `--current-kp`, `--current-ki` | Firmware gains for the run (default: leave as is) | +| `--position-kp`, `--position-ki`, `--position-kd`, `--speed-kp`, `--speed-ki`, `--current-kp`, `--current-ki` | Firmware gains for the run (default: leave as is). The tool prints the motor's live values at start; the dashboard tab shows them next to each field and seeds its sliders there | | `--persist` | Write gains to ROM instead of RAM | | `--keep` | Leave the run's gains and planner acceleration in the motor | | `--buzz-abort DEG` | Abort past this >10 Hz position motion, degrees RMS over 0.1 s (default: 0.3; 0 off) | diff --git a/web/app/src/components/diagnostics/tuning-workbench.tsx b/web/app/src/components/diagnostics/tuning-workbench.tsx index d01c4d99..1034a8f2 100644 --- a/web/app/src/components/diagnostics/tuning-workbench.tsx +++ b/web/app/src/components/diagnostics/tuning-workbench.tsx @@ -21,6 +21,7 @@ import { type TuningRunData, type TuningRunMeta, } from "@/lib/tuning" +import { fetchMotorDetails } from "@/lib/telemetry" const COMMANDED_COLOR = "rgba(255,255,255,0.45)" const ACTUAL_COLOR = "#eff483" @@ -68,6 +69,12 @@ interface WbField { * and an empty box means "run with config". */ gainKey?: string + /** + * Key into the selected motor's *firmware* loop gains (`position_kp`, + * `speed_kp`, …), read live from the motor over the idle link: the field + * shows that value as its baseline and an empty box runs with it. + */ + fwGainKey?: string /** Render a slider next to the value box, over this range. */ slider?: { min: number; max: number; step: number } } @@ -137,6 +144,72 @@ const GAIN_FIELDS: WbField[] = [ }, ] +/** + * The firmware loop gains of the Firmware-loop tab. Each shows the selected + * motor's *live* value ("motor N", read over the idle link when arm and joint + * are picked) and a slider seeded there; an empty box runs with the motor's + * value. Ranges are deliberately tight: on the X8 shoulders 3× the stock + * speed_kp already vibrated, so a sweep steps in small increments. + */ +const FW_GAIN_FIELDS: WbField[] = [ + { + key: "position_kp", + label: "position_kp", + type: "text", + fwGainKey: "position_kp", + slider: { min: 0, max: 0.3, step: 0.001 }, + hint: "firmware position loop P — lag ∝ 1/kp; X8 shoulders read 0.008, elbow 0.06", + }, + { + key: "position_ki", + label: "position_ki", + type: "text", + fwGainKey: "position_ki", + slider: { min: 0, max: 0.02, step: 0.0001 }, + hint: "firmware position loop I", + }, + { + key: "position_kd", + label: "position_kd", + type: "text", + fwGainKey: "position_kd", + slider: { min: 0, max: 2, step: 0.01 }, + hint: "firmware position loop D", + }, + { + key: "speed_kp", + label: "speed_kp", + type: "text", + fwGainKey: "speed_kp", + slider: { min: 0, max: 0.15, step: 0.001 }, + hint: "firmware speed loop P — the loop that cycles at creep; 0.1 vibrated on shoulder_1 (stock 0.03)", + }, + { + key: "speed_ki", + label: "speed_ki", + type: "text", + fwGainKey: "speed_ki", + slider: { min: 0, max: 0.005, step: 0.00005 }, + hint: "firmware speed loop I — what pushes through stiction", + }, + { + key: "current_kp", + label: "current_kp", + type: "text", + fwGainKey: "current_kp", + slider: { min: 0, max: 2, step: 0.01 }, + hint: "firmware current loop P — leave unless the vendor says otherwise", + }, + { + key: "current_ki", + label: "current_ki", + type: "text", + fwGainKey: "current_ki", + slider: { min: 0, max: 0.5, step: 0.001 }, + hint: "firmware current loop I", + }, +] + const TABS: WbTab[] = [ { key: "sine", @@ -293,13 +366,7 @@ const TABS: WbTab[] = [ placeholder: "stored", hint: "0 = direct PI tracking (required to follow the stream); restored after the run unless kept", }, - { key: "position_kp", label: "position_kp", type: "number", placeholder: "stock", hint: "X8 shoulders ship 0.008" }, - { key: "position_ki", label: "position_ki", type: "number", placeholder: "stock" }, - { key: "position_kd", label: "position_kd", type: "number", placeholder: "stock" }, - { key: "speed_kp", label: "speed_kp", type: "number", placeholder: "stock", hint: "X8 shoulders ship 0.03; 0.1 vibrated — step in small increments" }, - { key: "speed_ki", label: "speed_ki", type: "number", placeholder: "stock" }, - { key: "current_kp", label: "current_kp", type: "number", placeholder: "stock" }, - { key: "current_ki", label: "current_ki", type: "number", placeholder: "stock" }, + ...FW_GAIN_FIELDS, { key: "buzz_abort", label: "buzz abort (°)", type: "number", placeholder: "0.3" }, { key: "iq_abort", label: "current abort (A)", type: "number", placeholder: "10" }, { key: "persist", label: "persist gains to ROM", type: "boolean" }, @@ -994,6 +1061,12 @@ function parseLiveProbe(lines: string[]): LiveProbe | null { return probe } +/** Firmware gains span 0.0001 … 1: four significant digits, no padding. */ +function fmtGain(v: unknown): string { + if (v == null || typeof v !== "number" || !Number.isFinite(v)) return "–" + return String(Number(v.toPrecision(4))) +} + function fmtNum(v: unknown, digits = 2): string { if (v == null || typeof v !== "number" || !Number.isFinite(v)) return "–" const a = Math.abs(v) @@ -1917,6 +1990,10 @@ export function TuningWorkbench({ // Effective per-joint config gains (defaults + calibration): the slider // baselines and "config N" labels on the gain fields. const [gains, setGains] = useState(null) + // The selected motor's live firmware loop gains (Firmware-loop tab): read + // from the motor over the idle link whenever arm/joint change or a run + // ends, so the baselines are what the motor actually holds right now. + const [fwGains, setFwGains] = useState | null>(null) const [runs, setRuns] = useState([]) const [loading, setLoading] = useState(false) @@ -2086,6 +2163,24 @@ export function TuningWorkbench({ ) const tabValues = useMemo(() => values[tab.key] ?? {}, [values, tab.key]) + const fwArm = tabValues["arm"] ?? "" + const fwJoint = tabValues["joint"] ?? "" + useEffect(() => { + if (tabKey !== "a4" || !fwArm || !fwJoint || runningOurs) return + let stale = false + setFwGains(null) + fetchMotorDetails(fwArm, fwJoint.toUpperCase()) + .then((d) => { + if (!stale) setFwGains(d.gains) + }) + .catch(() => { + if (!stale) setFwGains(null) + }) + return () => { + stale = true + } + }, [tabKey, fwArm, fwJoint, runningOurs]) + // Sine and step probe the same joint with the same gains, so their shared // fields (arm, joint, kp/kd/kd_host/…, amp, rate, …) behave as one set: // switching between the two tabs carries the current values across — @@ -2136,6 +2231,10 @@ export function TuningWorkbench({ */ const configValue = useCallback( (f: WbField): number | null => { + if (f.fwGainKey) { + const v = fwGains?.[f.fwGainKey] + return typeof v === "number" && Number.isFinite(v) ? v : null + } if (!f.gainKey || !gains) return null const side = tabValues["arm"] const joint = tabValues["joint"] @@ -2143,7 +2242,7 @@ export function TuningWorkbench({ const v = gains[side]?.[joint]?.[f.gainKey] return typeof v === "number" && Number.isFinite(v) ? v : null }, - [gains, tabValues] + [gains, fwGains, tabValues] ) const meta = run?.meta ?? null @@ -2302,7 +2401,15 @@ export function TuningWorkbench({ {f.label} {tab.required.includes(f.key) && *} - {cfg != null && · config {fmtNum(cfg)}} + {cfg != null && ( + + {f.fwGainKey ? " · motor " : " · config "} + {f.fwGainKey ? fmtGain(cfg) : fmtNum(cfg)} + + )} + {f.fwGainKey && cfg == null && fwArm && fwJoint && ( + · motor … + )} {f.type === "overrides" ? ( setValue(f.key, e.target.value)} disabled={runningOurs || busy} From e02ea6fde9af88ce0ca937270ed93b0f877b9c85 Mon Sep 17 00:00:00 2001 From: Shawn Patel Date: Fri, 18 Sep 2026 17:16:33 -0700 Subject: [PATCH 03/80] Workbench: fix duplicate fmtGain, expose stiction/dither in the override table The new firmware-gain formatter collided with the override table's fmtGain (TS2393 on the Vercel build); it is fmtFwGain now, and the gain-box baseline text lives in one helper instead of a nested ternary. The tune.motion override table also gains stiction_gain, stiction_load_gain and dither_nm columns, fed by /api/tuning/gains, so those terms can be A/B'd from the UI. Co-Authored-By: Claude Fable 5.1 --- almond_axol/serve/app.py | 3 ++ .../diagnostics/tuning-workbench.tsx | 36 ++++++++++++++----- 2 files changed, 30 insertions(+), 9 deletions(-) diff --git a/almond_axol/serve/app.py b/almond_axol/serve/app.py index 872bf147..2dc5a97a 100644 --- a/almond_axol/serve/app.py +++ b/almond_axol/serve/app.py @@ -1965,6 +1965,9 @@ def _load() -> dict[str, Any]: jc.kd_host_q if jc.kd_host_q is not None else DAMP_BP_Q ), "j_eff": jc.j_eff, + "stiction_gain": jc.stiction_gain, + "stiction_load_gain": jc.stiction_load_gain, + "dither_nm": jc.dither_nm, } out[side] = joints return out diff --git a/web/app/src/components/diagnostics/tuning-workbench.tsx b/web/app/src/components/diagnostics/tuning-workbench.tsx index 1034a8f2..5b58806d 100644 --- a/web/app/src/components/diagnostics/tuning-workbench.tsx +++ b/web/app/src/components/diagnostics/tuning-workbench.tsx @@ -182,7 +182,9 @@ const FW_GAIN_FIELDS: WbField[] = [ type: "text", fwGainKey: "speed_kp", slider: { min: 0, max: 0.15, step: 0.001 }, - hint: "firmware speed loop P — the loop that cycles at creep; 0.1 vibrated on shoulder_1 (stock 0.03)", + hint: + "firmware speed loop P — the loop that cycles at creep; 0.1 vibrated on shoulder_1 " + + "(stock 0.03)", }, { key: "speed_ki", @@ -364,7 +366,9 @@ const TABS: WbTab[] = [ label: "planner accel (dps/s)", type: "number", placeholder: "stored", - hint: "0 = direct PI tracking (required to follow the stream); restored after the run unless kept", + hint: + "0 = direct PI tracking (required to follow the stream); restored after the run " + + "unless kept", }, ...FW_GAIN_FIELDS, { key: "buzz_abort", label: "buzz abort (°)", type: "number", placeholder: "0.3" }, @@ -654,7 +658,17 @@ const KIND_TABS: Record = { /* ------------------------------------------------------------------ */ // Matches tune.motion's --gain fields (see _GAIN_FIELDS there). -const OVERRIDE_FIELDS = ["kp", "kd", "kd_host", "kd_host_hz", "kd_host_q", "j_eff"] +const OVERRIDE_FIELDS = [ + "kp", + "kd", + "kd_host", + "kd_host_hz", + "kd_host_q", + "j_eff", + "stiction_gain", + "stiction_load_gain", + "dither_nm", +] /** Format a config gain for seeding/comparison (trims float32 noise). */ function fmtGain(v: unknown): string { @@ -1061,12 +1075,18 @@ function parseLiveProbe(lines: string[]): LiveProbe | null { return probe } -/** Firmware gains span 0.0001 … 1: four significant digits, no padding. */ -function fmtGain(v: unknown): string { +/** Firmware loop gains span 0.0001 … 1: four significant digits, no padding. */ +function fmtFwGain(v: unknown): string { if (v == null || typeof v !== "number" || !Number.isFinite(v)) return "–" return String(Number(v.toPrecision(4))) } +/** The baseline a gain box falls back to: the motor's live value, or config. */ +function baselineText(f: WbField, cfg: number | null): string { + if (cfg == null) return f.fwGainKey ? "motor" : "config" + return f.fwGainKey ? fmtFwGain(cfg) : fmtNum(cfg) +} + function fmtNum(v: unknown, digits = 2): string { if (v == null || typeof v !== "number" || !Number.isFinite(v)) return "–" const a = Math.abs(v) @@ -2404,7 +2424,7 @@ export function TuningWorkbench({ {cfg != null && ( {f.fwGainKey ? " · motor " : " · config "} - {f.fwGainKey ? fmtGain(cfg) : fmtNum(cfg)} + {baselineText(f, cfg)} )} {f.fwGainKey && cfg == null && fwArm && fwJoint && ( @@ -2491,9 +2511,7 @@ export function TuningWorkbench({ type="text" inputMode="decimal" value={tabValues[f.key] ?? ""} - placeholder={ - cfg != null ? (f.fwGainKey ? fmtGain(cfg) : fmtNum(cfg)) : f.fwGainKey ? "motor" : "config" - } + placeholder={baselineText(f, cfg)} title={f.hint} onChange={(e) => setValue(f.key, e.target.value)} disabled={runningOurs || busy} From e8691cceeff215c50ccec91b9ffda36f20f09c56 Mon Sep 17 00:00:00 2001 From: Shawn Patel Date: Fri, 18 Sep 2026 17:21:59 -0700 Subject: [PATCH 04/80] tune.a4: read the joint before holding it after an abort; current limit 30 A MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The raw 0xA4 stream never fills the driver's position cache, so the post-abort hold read a position that was not there and the run died before scoring. The 10 A default current limit tripped on a loaded shoulder's gravity current alone (right shoulder_1 at -54° holds ~10 A); it is 30 A now and documented as pose-dependent. The stored planner acceleration is printed at the start of every run. Co-Authored-By: Claude Fable 5.1 --- almond_axol/cli/tune/a4.py | 16 ++++++++++++---- docs/cli/tune-a4.mdx | 2 +- .../components/diagnostics/tuning-workbench.tsx | 8 +++++++- 3 files changed, 20 insertions(+), 6 deletions(-) diff --git a/almond_axol/cli/tune/a4.py b/almond_axol/cli/tune/a4.py index bb2ff0e4..911edb94 100644 --- a/almond_axol/cli/tune/a4.py +++ b/almond_axol/cli/tune/a4.py @@ -25,7 +25,8 @@ * A buzz guard watches the fine position for high-frequency motion and the reply current; past ``--buzz-abort`` degrees of >10 Hz content or ``--iq-abort`` amps it restores the previous gains at once, holds, and - ends the run. Shoulder_1 at speed_kp 0.1 (3× stock) vibrated immediately + ends the run. Size the current limit for the pose: a loaded X8 shoulder + draws ~10 A holding gravity alone at -55°. Shoulder_1 at speed_kp 0.1 (3× stock) vibrated immediately on 2026-09-18; start every sweep from the stock values in small steps. * The joint under test holds position stiffly in this mode and will push back against contact up to motor torque. Keep the workspace clear. @@ -441,8 +442,10 @@ def add_parser(subparsers: argparse._SubParsersAction) -> None: # type: ignore[ p.add_argument( "--iq-abort", type=float, - default=10.0, - help="Abort past this reply current, amps (default: 10; 0 off)", + default=30.0, + help="Abort past this reply current, amps (default: 30; 0 off). A loaded X8 " + "shoulder draws ~10 A just holding gravity at -55°, so keep this well above " + "the pose's static current", ) p.add_argument( "--save-run", @@ -528,6 +531,9 @@ async def _run(args: argparse.Namespace) -> None: # Planner and gains: written after every mode switch/homing is # done (those reset the motor and reload ROM). stored_accel = await _read_accel(driver) + print( + f" planner accel/decel stored: {stored_accel[0]}/{stored_accel[1]} dps/s" + ) if args.accel is not None and stored_accel != (args.accel, args.accel): before_accel = stored_accel accel_used = await _write_accel(driver, args.accel, args.accel) @@ -571,7 +577,9 @@ async def _run(args: argparse.Namespace) -> None: await _write_gains(driver, before_gains, args.persist) print(" previous gains restored") before_gains = None - here = motor.position + # The raw 0xA4 stream never fills the driver's position cache; + # read the joint explicitly before holding it where it stopped. + here = await motor.get_position() await _hold(driver, motor, here, args.cap, 0.5) report_achieved_rate(log, args.rate) except KeyboardInterrupt: diff --git a/docs/cli/tune-a4.mdx b/docs/cli/tune-a4.mdx index e08da1a9..4178a5e3 100644 --- a/docs/cli/tune-a4.mdx +++ b/docs/cli/tune-a4.mdx @@ -33,7 +33,7 @@ Safety, built in: | `--persist` | Write gains to ROM instead of RAM | | `--keep` | Leave the run's gains and planner acceleration in the motor | | `--buzz-abort DEG` | Abort past this >10 Hz position motion, degrees RMS over 0.1 s (default: 0.3; 0 off) | -| `--iq-abort A` | Abort past this reply current (default: 10; 0 off) | +| `--iq-abort A` | Abort past this reply current (default: 30; 0 off). A loaded X8 shoulder draws ~10 A just holding gravity at −55°, so keep this above the pose's static current | | `--save-run` | Persist the run artifact | | `--label TEXT`, `--group ID` | Note and sweep id stored on the run | diff --git a/web/app/src/components/diagnostics/tuning-workbench.tsx b/web/app/src/components/diagnostics/tuning-workbench.tsx index 5b58806d..349bd179 100644 --- a/web/app/src/components/diagnostics/tuning-workbench.tsx +++ b/web/app/src/components/diagnostics/tuning-workbench.tsx @@ -372,7 +372,13 @@ const TABS: WbTab[] = [ }, ...FW_GAIN_FIELDS, { key: "buzz_abort", label: "buzz abort (°)", type: "number", placeholder: "0.3" }, - { key: "iq_abort", label: "current abort (A)", type: "number", placeholder: "10" }, + { + key: "iq_abort", + label: "current abort (A)", + type: "number", + placeholder: "30", + hint: "a loaded X8 shoulder holds ~10 A of gravity alone at -55°; keep this above the pose's static current", + }, { key: "persist", label: "persist gains to ROM", type: "boolean" }, { key: "keep", label: "keep gains + planner after run", type: "boolean" }, { key: "label", label: "label", type: "text", placeholder: "note", width: "w-40" }, From f2c589d64834f39611566dda9869420b7271db26 Mon Sep 17 00:00:00 2001 From: Shawn Patel Date: Fri, 18 Sep 2026 17:27:45 -0700 Subject: [PATCH 05/80] Firmware-loop tab: show the motor's stored planner acceleration as the accel baseline The motor readout now carries the 0xA4 position planner's stored accel/decel (MyActuator only, via a new get_planner_acceleration on the driver), and the workbench's planner-accel field shows it as "motor N" beside the label with the value as its placeholder, the same way the loop gains do, so a 0 left in a joint is visible before a run and can be edited from the same box. Co-Authored-By: Claude Fable 5.1 --- almond_axol/motor/myactuator.py | 19 ++++++++++++++++ almond_axol/serve/robot_link.py | 11 ++++++++++ .../diagnostics/tuning-workbench.tsx | 22 ++++++++++++++----- web/app/src/lib/telemetry.ts | 2 ++ 4 files changed, 48 insertions(+), 6 deletions(-) diff --git a/almond_axol/motor/myactuator.py b/almond_axol/motor/myactuator.py index 4557c639..c5915d2b 100644 --- a/almond_axol/motor/myactuator.py +++ b/almond_axol/motor/myactuator.py @@ -63,6 +63,7 @@ "position_kd": 0x09, } _MA_SET_ACCELERATION = 0x43 # write acceleration to RAM and ROM; persistent by command +_MA_READ_ACCELERATION = 0x42 # read one acceleration type; int32 dps/s in bytes 4-7 # Configuration-parameter access. These two commands are absent from MyActuator's # published protocol (V4.4) — they were recovered from the vendor setup software, @@ -457,6 +458,24 @@ async def is_holding(self) -> bool: async def get_firmware_version(self) -> int | None: return await self._read_firmware_version() + async def get_planner_acceleration(self) -> tuple[int, int]: + """The position planner's stored ``(acceleration, deceleration)`` in + dps/s (0x42 types 0x00 / 0x01). + + 0 puts the position loop (0xA4) in direct PI tracking of each new + target, which a streamed trajectory needs; any other value makes the + firmware plan a velocity profile to every target and a 200 Hz stream + then never gets going. Note that a joint left at 0 executes a stored + target at its speed cap the moment it wakes. + """ + out: list[int] = [] + for kind in (_MA_ACC_POS_PLAN, _MA_DEC_POS_PLAN): + resp = await self._request( + bytes([_MA_READ_ACCELERATION, kind, 0, 0, 0, 0, 0, 0]) + ) + out.append(int(struct.unpack_from(" str | None: return await self._read_model() diff --git a/almond_axol/serve/robot_link.py b/almond_axol/serve/robot_link.py index 6b86c3f3..d2970cd6 100644 --- a/almond_axol/serve/robot_link.py +++ b/almond_axol/serve/robot_link.py @@ -1164,6 +1164,16 @@ async def read(coro: Any) -> Any: status = await read(motor.get_error_code()) mode = await read(motor.get_control_mode()) gains = await read(motor.get_gains()) + # MyActuator only: the 0xA4 position planner's stored acceleration — + # 0 = direct tracking (what wire_mode a4 and tune.a4 need), anything + # else re-plans every streamed target. Shown and edited on the + # dashboard's Firmware-loop tab next to the loop gains. + planner = None + driver = getattr(motor, "_driver", None) + if isinstance(driver, MyActuatorMotor): + acc = await read(driver.get_planner_acceleration()) + if acc is not None: + planner = {"accel": acc[0], "decel": acc[1]} return { "arm": arm_link.side, "joint": joint.name, @@ -1177,4 +1187,5 @@ async def read(coro: Any) -> Any: "temperature": await read(motor.get_temperature()), "voltage": await read(motor.get_voltage()), "gains": vars(gains) if gains is not None else None, + "planner": planner, } diff --git a/web/app/src/components/diagnostics/tuning-workbench.tsx b/web/app/src/components/diagnostics/tuning-workbench.tsx index 349bd179..7a42a81b 100644 --- a/web/app/src/components/diagnostics/tuning-workbench.tsx +++ b/web/app/src/components/diagnostics/tuning-workbench.tsx @@ -364,11 +364,12 @@ const TABS: WbTab[] = [ { key: "accel", label: "planner accel (dps/s)", - type: "number", - placeholder: "stored", + type: "text", + fwGainKey: "planner_accel", + width: "w-24", hint: - "0 = direct PI tracking (required to follow the stream); restored after the run " + - "unless kept", + "shows what the motor stores; 0 = direct PI tracking (required to follow the " + + "stream). Written for the run and restored afterwards unless kept", }, ...FW_GAIN_FIELDS, { key: "buzz_abort", label: "buzz abort (°)", type: "number", placeholder: "0.3" }, @@ -377,7 +378,9 @@ const TABS: WbTab[] = [ label: "current abort (A)", type: "number", placeholder: "30", - hint: "a loaded X8 shoulder holds ~10 A of gravity alone at -55°; keep this above the pose's static current", + hint: + "a loaded X8 shoulder holds ~10 A of gravity alone at -55°; keep this above the " + + "pose's static current", }, { key: "persist", label: "persist gains to ROM", type: "boolean" }, { key: "keep", label: "keep gains + planner after run", type: "boolean" }, @@ -2197,7 +2200,14 @@ export function TuningWorkbench({ setFwGains(null) fetchMotorDetails(fwArm, fwJoint.toUpperCase()) .then((d) => { - if (!stale) setFwGains(d.gains) + if (stale) return + // Loop gains plus the planner acceleration, under one lookup so the + // accel field gets the same "motor N" baseline as the gains. + setFwGains({ + ...(d.gains ?? {}), + planner_accel: d.planner?.accel ?? null, + planner_decel: d.planner?.decel ?? null, + }) }) .catch(() => { if (!stale) setFwGains(null) diff --git a/web/app/src/lib/telemetry.ts b/web/app/src/lib/telemetry.ts index 2b4fa051..5b8dc172 100644 --- a/web/app/src/lib/telemetry.ts +++ b/web/app/src/lib/telemetry.ts @@ -149,6 +149,8 @@ export interface MotorDetails { temperature: number | null voltage: number | null gains: Record | null + /** MyActuator only: the 0xA4 position planner's stored accel/decel (dps/s). */ + planner: { accel: number | null; decel: number | null } | null } export async function fetchMotorDetails(arm: string, joint: string): Promise { From 5fa5a72be3e4998380303bdeb30d1450920ce992 Mon Sep 17 00:00:00 2001 From: Shawn Patel Date: Fri, 18 Sep 2026 18:36:51 -0700 Subject: [PATCH 06/80] tune.friction --raw-csv / --bins, and scripts/cogging_map.py for position-periodic torque MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The sweep's CSV averaged 40 bins over the joint range (~3° each), too coarse to see a sub-degree cogging or gear-mesh period. --raw-csv writes every cruise sample; cogging_map.py averages fwd/bwd passes on a fine angle grid, detrends, and analyses the residual in the angle domain, reporting spatial peaks per speed and the cross-speed correlation (cogging repeats, stick-slip does not) and optionally writing a cancellation table. Verified on a synthetic sweep with a planted 0.94° / 0.4 Nm ripple (recovered at 0.93°, r = 0.92 across speeds). Co-Authored-By: Claude Fable 5.1 --- almond_axol/cli/tune/friction.py | 56 ++++++++- scripts/cogging_map.py | 190 +++++++++++++++++++++++++++++++ 2 files changed, 243 insertions(+), 3 deletions(-) create mode 100644 scripts/cogging_map.py diff --git a/almond_axol/cli/tune/friction.py b/almond_axol/cli/tune/friction.py index 67df58d0..b950c486 100644 --- a/almond_axol/cli/tune/friction.py +++ b/almond_axol/cli/tune/friction.py @@ -379,6 +379,8 @@ async def _identify_joint( lo_override: float | None = None, hi_override: float | None = None, dump_csv: Path | None = None, + raw_csv: Path | None = None, + n_bins: int = _N_BINS, ) -> tuple[list[tuple[float, float]], list[tuple[float, float]]]: """Run bidirectional multi-velocity sweep over the full joint range. @@ -389,6 +391,11 @@ async def _identify_joint( If ``dump_csv`` is given, every matched (fwd, bwd) bin is also written to a CSV with the per-velocity, per-position torque values. Useful for plotting the raw friction-vs-velocity curve and comparing arms. + + ``raw_csv`` writes every cruise sample (``q``, ``tau``, speed, direction) + unbinned — the input for a position-periodic (cogging / gear-mesh) torque + analysis, which needs sub-degree resolution the bins do not keep (see + ``scripts/cogging_map.py``). ``n_bins`` sets the bin count for the fit. """ lo, hi = arm_limits(joint, is_left) if lo_override is not None: @@ -413,6 +420,15 @@ async def _identify_joint( csv_file = None csv_writer = None + raw_file = None + raw_writer = None + if raw_csv is not None: + raw_file = secure_open_new_text(raw_csv, newline="") + raw_writer = csv.writer(raw_file) + raw_writer.writerow( + ["joint", "side", "v_rad_s", "direction", "q_rad", "tau_nm"] + ) + print(f" Dumping every cruise sample to {raw_csv}") if dump_csv is not None: csv_file = secure_open_new_text(dump_csv, newline="") csv_writer = csv.writer(csv_file) @@ -449,11 +465,26 @@ async def _identify_joint( bwd = await _run_sweep_raw(motor, kp, kd, cur, -v, sweep_lo) print(f" bwd: {len(bwd)} samples") + if raw_writer is not None: + side_name = "left" if is_left else "right" + for direction, rows in (("+", fwd), ("-", bwd)): + for q, tau in rows: + raw_writer.writerow( + [ + joint.value, + side_name, + f"{v:.6f}", + direction, + f"{q:.6f}", + f"{tau:.6f}", + ] + ) + raw_file.flush() # type: ignore[union-attr] - fwd_bins = _bin_by_position(fwd, sweep_lo, sweep_hi) - bwd_bins = _bin_by_position(bwd, sweep_lo, sweep_hi) + fwd_bins = _bin_by_position(fwd, sweep_lo, sweep_hi, n_bins) + bwd_bins = _bin_by_position(bwd, sweep_lo, sweep_hi, n_bins) matched = sum(1 for q in fwd_bins if q in bwd_bins) - print(f" {matched}/{_N_BINS} position bins matched") + print(f" {matched}/{n_bins} position bins matched") for q_center, tau_f in fwd_bins.items(): if q_center in bwd_bins: @@ -483,6 +514,8 @@ async def _identify_joint( finally: if csv_file is not None: csv_file.close() + if raw_file is not None: + raw_file.close() return all_avg, all_halfdiff @@ -534,6 +567,21 @@ def add_parser(subparsers: argparse._SubParsersAction) -> None: # type: ignore[ metavar="DEG", help="Override upper joint limit for the sweep (degrees)", ) + p.add_argument( + "--raw-csv", + type=Path, + default=None, + metavar="PATH", + help="Write every cruise sample (q, tau, speed, direction) unbinned — the " + "input for scripts/cogging_map.py, which looks for position-periodic " + "torque (cogging / gear mesh) and builds a feedforward table from it", + ) + p.add_argument( + "--bins", + type=int, + default=_N_BINS, + help=f"Position bins the fwd/bwd matching and fit use (default: {_N_BINS})", + ) p.add_argument( "--dump-csv", nargs="?", @@ -649,6 +697,8 @@ async def _run(args: argparse.Namespace) -> None: if args.hi is not None else hi_default, dump_csv=dump_csv, + raw_csv=args.raw_csv, + n_bins=args.bins, ) if not avg_samples and not halfdiff_samples: diff --git a/scripts/cogging_map.py b/scripts/cogging_map.py new file mode 100644 index 00000000..d0e39948 --- /dev/null +++ b/scripts/cogging_map.py @@ -0,0 +1,190 @@ +"""Look for position-periodic torque (cogging / gear mesh) in a friction sweep +and turn it into a feedforward table. + +Input is the ``--raw-csv`` of ``axol tune.friction``: every cruise sample of a +bidirectional constant-speed sweep, ``(joint, side, v_rad_s, direction, +q_rad, tau_nm)``. The forward and backward passes at one speed are averaged +on a fine angle grid, which cancels the speed-dependent friction and leaves +gravity plus anything that depends on *position*. A smooth trend (the gravity +model's residual) is removed, and the remainder is analysed in the **angle** +domain: cogging and gear mesh have a fixed period in degrees of travel and +line up across speeds, whereas stick-slip and structural ringing have a fixed +period in *time* and therefore change spatial period with speed. + +Usage: + uv run python scripts/cogging_map.py ~/fric-s1-raw.csv + uv run python scripts/cogging_map.py ~/fric-s1-raw.csv --grid-deg 0.1 --table ~/cog-s1.json + +The table (``--table``) is ``{"joint", "side", "grid_deg", "q_deg": [...], +"tau_nm": [...]}``: the periodic torque to *add* to the feedforward at each +angle so the motor cancels it. Only worth wiring in if the report shows a +peak that is consistent across speeds and well above the noise floor. +""" + +from __future__ import annotations + +import argparse +import csv +import json +import math +from collections import defaultdict +from pathlib import Path + +import numpy as np + + +def grid_average(q: np.ndarray, tau: np.ndarray, grid: np.ndarray) -> np.ndarray: + """Mean torque per grid cell (NaN where empty).""" + idx = np.clip(np.searchsorted(grid, q) - 1, 0, len(grid) - 2) + sums = np.zeros(len(grid) - 1) + counts = np.zeros(len(grid) - 1) + np.add.at(sums, idx, tau) + np.add.at(counts, idx, 1) + with np.errstate(invalid="ignore"): + return np.where(counts > 0, sums / np.maximum(counts, 1), np.nan) + + +def detrend(x: np.ndarray, y: np.ndarray, degree: int = 5) -> np.ndarray: + """Remove a low-order polynomial (the gravity residual and any drift).""" + ok = np.isfinite(y) + coef = np.polyfit(x[ok], y[ok], degree) + return y - np.polyval(coef, x) + + +def angle_spectrum(y: np.ndarray, grid_deg: float) -> tuple[np.ndarray, np.ndarray]: + """Power spectrum against spatial frequency (cycles per degree).""" + ok = np.isfinite(y) + z = np.where(ok, y, 0.0) + z = z - z[ok].mean() + window = np.hanning(len(z)) + power = np.abs(np.fft.rfft(z * window)) ** 2 + freq = np.fft.rfftfreq(len(z), grid_deg) + return freq, power + + +def analyse(path: Path, grid_deg: float, table: Path | None) -> None: + by_speed: dict[float, dict[str, list[tuple[float, float]]]] = defaultdict( + lambda: {"+": [], "-": []} + ) + joint = side = "" + with open(path, newline="") as f: + for row in csv.DictReader(f): + joint, side = row["joint"], row["side"] + by_speed[round(float(row["v_rad_s"]), 4)][row["direction"]].append( + (math.degrees(float(row["q_rad"])), float(row["tau_nm"])) + ) + if not by_speed: + raise SystemExit("no samples in the CSV") + all_q = np.array( + [q for d in by_speed.values() for rows in d.values() for q, _ in rows] + ) + lo, hi = np.floor(all_q.min()), np.ceil(all_q.max()) + grid = np.arange(lo, hi + grid_deg, grid_deg) + centres = grid[:-1] + grid_deg / 2 + + print( + f"{side} {joint}: {len(all_q)} samples over {lo:.0f}..{hi:.0f}°, {grid_deg}° grid" + ) + print( + f"{'speed':>8s} {'periodic RMS':>13s} {'noise floor':>12s} {'top spatial peaks (° per cycle : Nm)':>40s}" + ) + residuals: dict[float, np.ndarray] = {} + for v in sorted(by_speed): + fwd = np.array(by_speed[v]["+"]) if by_speed[v]["+"] else np.empty((0, 2)) + bwd = np.array(by_speed[v]["-"]) if by_speed[v]["-"] else np.empty((0, 2)) + if len(fwd) < 50 or len(bwd) < 50: + continue + f_avg = grid_average(fwd[:, 0], fwd[:, 1], grid) + b_avg = grid_average(bwd[:, 0], bwd[:, 1], grid) + both = np.isfinite(f_avg) & np.isfinite(b_avg) + avg = np.where(both, (f_avg + b_avg) / 2.0, np.nan) + # Restrict to the span both directions covered. + ok = np.flatnonzero(both) + if len(ok) < 20: + continue + span = slice(ok[0], ok[-1] + 1) + x = centres[span] + y = avg[span] + # Fill small gaps by interpolation so the spectrum is not spiked. + good = np.isfinite(y) + y = np.interp(x, x[good], y[good]) + resid = detrend(x, y) + residuals[v] = np.interp(centres, x, resid, left=np.nan, right=np.nan) + freq, power = angle_spectrum(resid, grid_deg) + # Ignore anything slower than one cycle per 10° (that is the trend). + sel = freq > 0.1 + order = np.argsort(power[sel])[::-1][:3] + amps = ( + np.sqrt(power[sel][order] / power[sel].sum()) * resid.std() * math.sqrt(2) + ) + peaks = ", ".join( + f"{1 / freq[sel][i]:.2f}° : {a:.3f}" for i, a in zip(order, amps) + ) + # Noise floor: median spectral amplitude. + floor = ( + math.sqrt(np.median(power[sel]) / power[sel].sum()) + * resid.std() + * math.sqrt(2) + ) + print( + f"{math.degrees(v):7.1f}°/s {resid.std():10.3f} Nm {floor:9.3f} Nm {peaks}" + ) + + if len(residuals) >= 2: + speeds = sorted(residuals) + stack = np.array([residuals[v] for v in speeds]) + common = np.all(np.isfinite(stack), axis=0) + if common.sum() > 20: + c = np.corrcoef(stack[:, common]) + pairs = [ + (math.degrees(speeds[i]), math.degrees(speeds[j]), c[i, j]) + for i in range(len(speeds)) + for j in range(i + 1, len(speeds)) + ] + print( + "\ncorrelation of the position residual between speeds (cogging repeats, stick-slip does not):" + ) + for a, b, r in pairs: + print(f" {a:.1f} vs {b:.1f} °/s: r = {r:+.2f}") + mean_resid = stack[:, common].mean(axis=0) + print( + f"speed-averaged periodic torque: {mean_resid.std():.3f} Nm RMS, {np.ptp(mean_resid):.3f} Nm peak-to-peak" + ) + if table is not None: + out = { + "joint": joint, + "side": side, + "grid_deg": grid_deg, + "q_deg": [float(q) for q in centres[common]], + "tau_nm": [float(-t) for t in mean_resid], + "note": "feedforward to ADD at each joint-frame angle to cancel the measured position-periodic torque", + } + table.write_text(json.dumps(out, indent=1)) + print(f"table → {table}") + print( + "\nRead it as: a peak that sits at the same ° per cycle at every speed with r above ~0.7 " + "between speeds is cogging or gear mesh and can be cancelled from a table; peaks that " + "move with speed are time-domain (stick-slip, structural) and cannot." + ) + + +def main() -> None: + p = argparse.ArgumentParser( + description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter + ) + p.add_argument("csv", type=Path, help="--raw-csv output of axol tune.friction") + p.add_argument( + "--grid-deg", type=float, default=0.1, help="Angle grid (default: 0.1°)" + ) + p.add_argument( + "--table", + type=Path, + default=None, + help="Write the cancellation table here (JSON)", + ) + args = p.parse_args() + analyse(args.csv, args.grid_deg, args.table) + + +if __name__ == "__main__": + main() From f4cba846ed18dfbed9f3135bb860dfd4aa69d3d0 Mon Sep 17 00:00:00 2001 From: Shawn Patel Date: Fri, 18 Sep 2026 18:46:23 -0700 Subject: [PATCH 07/80] tune.friction raw CSV carries the pass index; cogging_map correlates pass to pass Three passes at one speed are the strictest repeatability test for a position table, and the analysis merged them by speed. The raw CSV now records the pass index and the analysis keys on (pass, speed). Co-Authored-By: Claude Fable 5.1 --- almond_axol/cli/tune/friction.py | 5 +++-- scripts/cogging_map.py | 38 ++++++++++++++++++-------------- 2 files changed, 25 insertions(+), 18 deletions(-) diff --git a/almond_axol/cli/tune/friction.py b/almond_axol/cli/tune/friction.py index b950c486..624344a3 100644 --- a/almond_axol/cli/tune/friction.py +++ b/almond_axol/cli/tune/friction.py @@ -426,7 +426,7 @@ async def _identify_joint( raw_file = secure_open_new_text(raw_csv, newline="") raw_writer = csv.writer(raw_file) raw_writer.writerow( - ["joint", "side", "v_rad_s", "direction", "q_rad", "tau_nm"] + ["joint", "side", "pass", "v_rad_s", "direction", "q_rad", "tau_nm"] ) print(f" Dumping every cruise sample to {raw_csv}") if dump_csv is not None: @@ -447,7 +447,7 @@ async def _identify_joint( print(f" Dumping per-bin samples to {dump_csv}") try: - for v in velocities: + for pass_index, v in enumerate(velocities): print(f"\n v = {math.degrees(v):.1f} deg/s ...") # Ramp to sweep start with time proportional to distance @@ -473,6 +473,7 @@ async def _identify_joint( [ joint.value, side_name, + pass_index, f"{v:.6f}", direction, f"{q:.6f}", diff --git a/scripts/cogging_map.py b/scripts/cogging_map.py index d0e39948..9d16c962 100644 --- a/scripts/cogging_map.py +++ b/scripts/cogging_map.py @@ -63,14 +63,18 @@ def angle_spectrum(y: np.ndarray, grid_deg: float) -> tuple[np.ndarray, np.ndarr def analyse(path: Path, grid_deg: float, table: Path | None) -> None: - by_speed: dict[float, dict[str, list[tuple[float, float]]]] = defaultdict( - lambda: {"+": [], "-": []} + # Keyed by (pass index, speed) so repeated passes at one speed stay apart — + # pass-to-pass repeatability at the same speed is the strictest test of + # whether a position table could cancel anything. + by_speed: dict[tuple[int, float], dict[str, list[tuple[float, float]]]] = ( + defaultdict(lambda: {"+": [], "-": []}) ) joint = side = "" with open(path, newline="") as f: for row in csv.DictReader(f): joint, side = row["joint"], row["side"] - by_speed[round(float(row["v_rad_s"]), 4)][row["direction"]].append( + key = (int(row.get("pass", 0) or 0), round(float(row["v_rad_s"]), 4)) + by_speed[key][row["direction"]].append( (math.degrees(float(row["q_rad"])), float(row["tau_nm"])) ) if not by_speed: @@ -88,10 +92,11 @@ def analyse(path: Path, grid_deg: float, table: Path | None) -> None: print( f"{'speed':>8s} {'periodic RMS':>13s} {'noise floor':>12s} {'top spatial peaks (° per cycle : Nm)':>40s}" ) - residuals: dict[float, np.ndarray] = {} - for v in sorted(by_speed): - fwd = np.array(by_speed[v]["+"]) if by_speed[v]["+"] else np.empty((0, 2)) - bwd = np.array(by_speed[v]["-"]) if by_speed[v]["-"] else np.empty((0, 2)) + residuals: dict[tuple[int, float], np.ndarray] = {} + for key in sorted(by_speed): + _pass, v = key + fwd = np.array(by_speed[key]["+"]) if by_speed[key]["+"] else np.empty((0, 2)) + bwd = np.array(by_speed[key]["-"]) if by_speed[key]["-"] else np.empty((0, 2)) if len(fwd) < 50 or len(bwd) < 50: continue f_avg = grid_average(fwd[:, 0], fwd[:, 1], grid) @@ -109,7 +114,7 @@ def analyse(path: Path, grid_deg: float, table: Path | None) -> None: good = np.isfinite(y) y = np.interp(x, x[good], y[good]) resid = detrend(x, y) - residuals[v] = np.interp(centres, x, resid, left=np.nan, right=np.nan) + residuals[key] = np.interp(centres, x, resid, left=np.nan, right=np.nan) freq, power = angle_spectrum(resid, grid_deg) # Ignore anything slower than one cycle per 10° (that is the trend). sel = freq > 0.1 @@ -127,25 +132,26 @@ def analyse(path: Path, grid_deg: float, table: Path | None) -> None: * math.sqrt(2) ) print( - f"{math.degrees(v):7.1f}°/s {resid.std():10.3f} Nm {floor:9.3f} Nm {peaks}" + f"{math.degrees(v):5.1f}°/s #{_pass} {resid.std():8.3f} Nm {floor:9.3f} Nm {peaks}" ) if len(residuals) >= 2: - speeds = sorted(residuals) - stack = np.array([residuals[v] for v in speeds]) + keys = sorted(residuals) + stack = np.array([residuals[k] for k in keys]) common = np.all(np.isfinite(stack), axis=0) if common.sum() > 20: c = np.corrcoef(stack[:, common]) + label = lambda k: f"{math.degrees(k[1]):.1f}°/s #{k[0]}" # noqa: E731 pairs = [ - (math.degrees(speeds[i]), math.degrees(speeds[j]), c[i, j]) - for i in range(len(speeds)) - for j in range(i + 1, len(speeds)) + (label(keys[i]), label(keys[j]), c[i, j]) + for i in range(len(keys)) + for j in range(i + 1, len(keys)) ] print( - "\ncorrelation of the position residual between speeds (cogging repeats, stick-slip does not):" + "\ncorrelation of the position residual between passes (cogging repeats, stick-slip does not):" ) for a, b, r in pairs: - print(f" {a:.1f} vs {b:.1f} °/s: r = {r:+.2f}") + print(f" {a} vs {b}: r = {r:+.2f}") mean_resid = stack[:, common].mean(axis=0) print( f"speed-averaged periodic torque: {mean_resid.std():.3f} Nm RMS, {np.ptp(mean_resid):.3f} Nm peak-to-peak" From 51fe29e37c4f1fd03ef785fd997ed15db3882b89 Mon Sep 17 00:00:00 2001 From: Shawn Patel Date: Fri, 18 Sep 2026 19:04:01 -0700 Subject: [PATCH 08/80] Stribeck cancellation on measured velocity; load-proportional Coulomb friction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two feedforward terms the stick-slip data asks for, both default-off: - stribeck_gain / stribeck_dfs / stribeck_load_gain / stribeck_vs: the excess of low-speed over sliding friction, keyed on the *measured* velocity (20 rad/s low-pass of position in the core) with the measured curve's shape, amp·exp(-(v/vs)²)·tanh(v/0.02). Every earlier feedforward was a function of the commanded velocity or of position and so could not touch the velocity-weakening slope that drives the 2 Hz cycle; this one follows the real speed and flattens that slope. Zero at rest. Trace column stribeck_ff. - friction.fl: Coulomb level fc + fl·|gravity|. The breakaway probes on both branches found a constant fc over-states friction at rest and under-states it at reach; tune.friction now fits the slope when its sweep spans >= 3 Nm of load (today's 3-8 deg/s sweep on right shoulder_1: Fc0 0.83 Nm, Fl 0.017 Nm/Nm over 7.8 Nm), and saves it. - tune.motion --gain accepts joint.friction.fc/k/fv/fo/fl. Config protocol 6 -> 8; Rust math golden-pinned to the Python originals. Co-Authored-By: Claude Fable 5.1 --- almond_axol/cli/tune/friction.py | 74 +++++++++- almond_axol/cli/tune/motion.py | 20 ++- almond_axol/robot/axol.py | 24 +++- almond_axol/robot/config.py | 38 ++++- almond_axol/robot/control.py | 42 ++++++ almond_axol/rt/link.py | 2 +- almond_axol/rt/robot.py | 4 +- almond_axol/serve/app.py | 1 + almond_axol/teleop/recorder.py | 1 + docs/cli/tune-motion.mdx | 2 +- docs/snippets/config/robot.mdx | 5 + rust/axol-rt/src/bringup.rs | 24 ++++ rust/axol-rt/src/filter.rs | 52 +++++++ rust/axol-rt/src/hold.rs | 5 + rust/axol-rt/src/serve.rs | 132 ++++++++++++++---- tests/test_rom_partial_arm.py | 14 +- tests/test_rt_link.py | 5 +- tests/test_stiction.py | 60 ++++++++ .../diagnostics/tuning-workbench.tsx | 1 + 19 files changed, 464 insertions(+), 42 deletions(-) diff --git a/almond_axol/cli/tune/friction.py b/almond_axol/cli/tune/friction.py index 624344a3..b5e0977b 100644 --- a/almond_axol/cli/tune/friction.py +++ b/almond_axol/cli/tune/friction.py @@ -417,6 +417,9 @@ async def _identify_joint( all_avg: list[tuple[float, float]] = [] all_halfdiff: list[tuple[float, float]] = [] + # (q, v, tau_half): the half-difference against the pose it was taken at, + # for the load-dependent fit (gear friction grows with gravity torque). + all_halfdiff_q: list[tuple[float, float, float]] = [] csv_file = None csv_writer = None @@ -494,6 +497,7 @@ async def _identify_joint( tau_half = (tau_f - tau_b) / 2.0 all_avg.append((q_center, tau_avg)) all_halfdiff.append((v, tau_half)) + all_halfdiff_q.append((q_center, v, tau_half)) if csv_writer is not None: csv_writer.writerow( [ @@ -518,9 +522,51 @@ async def _identify_joint( if raw_file is not None: raw_file.close() + _identify_joint.last_halfdiff_q = all_halfdiff_q # type: ignore[attr-defined] return all_avg, all_halfdiff +def _fit_load_friction( + samples: list[tuple[float, float, float]], + joint: Joint, + is_left: bool, + other_targets: dict[Joint, float], + k_fixed: float, + fv_fixed: float, +) -> tuple[float, float, float] | None: + """Fit ``(Fc + Fl·|g(q)|)·tanh(0.1·k·v) + Fv·v`` to ``(q, v, tau_half)``. + + ``k`` and ``Fv`` are held at the constant-model fit so the two fits differ + only in how the Coulomb level depends on gravity load. Returns + ``(Fc0, Fl, load_span)`` — the zero-load Coulomb level, its slope per Nm + of gravity torque, and how many Nm of load the sweep spanned. A sweep that + stayed within ~3 Nm of load cannot separate the two and returns ``None``. + """ + if len(samples) < 8: + return None + gc = GravityCompensator() + test_idx = ARM_JOINTS.index(joint) + arm_q = np.zeros(len(ARM_JOINTS), dtype=np.float32) + for j, target in other_targets.items(): + if j in ARM_JOINTS and j != joint: + arm_q[ARM_JOINTS.index(j)] = float(target) + load = np.empty(len(samples)) + for i, (q, _v, _t) in enumerate(samples): + arm_q[test_idx] = float(q) + load[i] = abs(float(gc.gravity_arm(arm_q, is_left=is_left)[test_idx])) + span = float(np.ptp(load)) + if span < 3.0: + return None + v = np.array([s[1] for s in samples]) + tau = np.maximum(np.array([s[2] for s in samples]) - fv_fixed * v, 0.0) + sat = np.tanh(0.1 * k_fixed * v) + # Linear least squares in (Fc0, Fl): tau ≈ sat·Fc0 + (sat·load)·Fl. + A = np.c_[sat, sat * load] + coef, _, _, _ = np.linalg.lstsq(A, tau, rcond=None) + fc0, fl = float(coef[0]), float(coef[1]) + return max(fc0, 0.0), max(fl, 0.0), span + + def add_parser(subparsers: argparse._SubParsersAction) -> None: # type: ignore[type-arg] """Register the ``tune.friction`` subcommand.""" p = subparsers.add_parser( @@ -718,12 +764,37 @@ async def _run(args: argparse.Namespace) -> None: Fo_out = Fo_result if Fo_result is not None else 0.0 Fc_out = k_out = Fv_out = 0.0 + Fl_out = 0.0 if friction_result is not None: Fc_out, k_out, Fv_out = friction_result print("\n Fitted friction model: τ = Fc·tanh(0.1·k·v) + Fv·v + Fo") print(f" Fc = {Fc_out:.4f} Nm (Coulomb)") print(f" k = {k_out:.2f} (tanh steepness)") print(f" Fv = {Fv_out:.4f} Nm·s/rad (viscous)") + load_fit = _fit_load_friction( + getattr(_identify_joint, "last_halfdiff_q", []), + joint, + is_left, + other_targets, + k_out, + Fv_out, + ) + if load_fit is not None: + fc0, fl, span = load_fit + print( + f"\n Load-dependent Coulomb (gear friction grows with the torque it carries),\n" + f" fitted over {span:.1f} Nm of gravity-load variation:\n" + f" Fc0 = {fc0:.4f} Nm at zero load, Fl = {fl:.4f} Nm per Nm of gravity\n" + f" → {fc0 + fl * 5:.2f} Nm at 5 Nm, {fc0 + fl * 12:.2f} Nm at 12 Nm " + f"(constant model: {Fc_out:.2f} everywhere)" + ) + if fl > 0.0: + Fc_out, Fl_out = fc0, fl + else: + print( + "\n (load-dependent Coulomb not fitted: the sweep spanned < 3 Nm of " + "gravity load — pose the joint under load, e.g. --lo/--hi, to fit fl)" + ) if friction_result is not None or Fo_result is not None: if args.save and friction_result is None: @@ -744,6 +815,7 @@ async def _run(args: argparse.Namespace) -> None: "k": round(k_out, 2), "fv": round(Fv_out, 4), "fo": round(Fo_out, 4), + "fl": round(Fl_out, 4), }, ) print(f"\n Saved to {path}") @@ -758,7 +830,7 @@ async def _run(args: argparse.Namespace) -> None: ) print( f" FrictionParams(fc={Fc_out:.4f}, k={k_out:.2f}, " - f"fv={Fv_out:.4f}, fo={Fo_out:.4f})," + f"fv={Fv_out:.4f}, fo={Fo_out:.4f}, fl={Fl_out:.4f})," ) print(f"{'─' * 50}") diff --git a/almond_axol/cli/tune/motion.py b/almond_axol/cli/tune/motion.py index 49bb752b..308b990e 100644 --- a/almond_axol/cli/tune/motion.py +++ b/almond_axol/cli/tune/motion.py @@ -72,6 +72,17 @@ "stiction_err_deg", "dither_nm", "dither_hz", + "stribeck_gain", + "stribeck_dfs", + "stribeck_load_gain", + "stribeck_vs", + # Friction model, addressed as ``joint.friction.fc`` etc. — the sliding + # friction feedforward is the other half of every stick-slip A/B. + "friction.fc", + "friction.k", + "friction.fv", + "friction.fo", + "friction.fl", ) # Column names of a 14-wide motion row: left arm then right arm. @@ -94,6 +105,9 @@ def _parse_gain_overrides(specs: list[str]) -> dict[tuple[str, str, str], float] value = float(raw) except ValueError: raise SystemExit(f"--gain: bad value in {spec!r} (want PATH=NUMBER)") + # ``[side.]joint.friction.fc``: fold the sub-field back into one token. + if len(parts) >= 2 and parts[-2] == "friction": + parts = parts[:-2] + [f"friction.{parts[-1]}"] if len(parts) == 3: sides, joint, fld = [parts[0]], parts[1], parts[2] if sides[0] not in ("left", "right"): @@ -438,7 +452,11 @@ async def _run(args: argparse.Namespace) -> None: has_gripper=not args.no_gripper, ) for (side, joint, fld), value in overrides.items(): - setattr(getattr(getattr(config, side), joint), fld, value) + target = getattr(getattr(config, side), joint) + if fld.startswith("friction."): + setattr(target.friction, fld.split(".", 1)[1], value) + else: + setattr(target, fld, value) print(f" gain override: {side}.{joint}.{fld} = {value}") for spec in args.a4: parts = spec.split(".") diff --git a/almond_axol/robot/axol.py b/almond_axol/robot/axol.py index b70a0c3d..4f29a1bd 100644 --- a/almond_axol/robot/axol.py +++ b/almond_axol/robot/axol.py @@ -49,6 +49,8 @@ compute_friction, stiction_amplitude, stiction_compensation, + stribeck_amplitude, + stribeck_excess, ) from .gravity import GravityCompensator @@ -1703,10 +1705,30 @@ async def motion_control(self, q: np.ndarray) -> None: ), math.radians(gains.stiction_err_deg), ) + stribeck = 0.0 + if gains.stribeck_gain != 0.0: + motor = self.motors.get(j) + try: + v_now = motor.velocity if motor is not None else 0.0 + except MotorError: + v_now = 0.0 + stribeck = stribeck_excess( + v_now, + stribeck_amplitude( + gains.stribeck_gain, + gains.stribeck_dfs, + gains.stribeck_load_gain, + float(gravity[i]), + ), + gains.stribeck_vs, + ) t_ff = ( float(gravity[i]) - + compute_friction(velocities[i], f.fc, f.k, f.fv, f.fo) + + compute_friction( + velocities[i], f.fc + f.fl * abs(float(gravity[i])), f.k, f.fv, f.fo + ) + stiction + + stribeck + dither[i] + gains.j_eff * float(j_scale[i]) * accelerations[i] + float(host_scale[i]) * gains.kd_host * v_damp[i] diff --git a/almond_axol/robot/config.py b/almond_axol/robot/config.py index ce2e768d..09ec25ec 100644 --- a/almond_axol/robot/config.py +++ b/almond_axol/robot/config.py @@ -49,22 +49,33 @@ class FrictionParams: """tanh-Coulomb + viscous friction model. - ``τ_friction = fc · tanh(k · v) + fv · v + fo`` + ``τ_friction = (fc + fl · |τ_gravity|) · tanh(k · v) + fv · v + fo`` - where ``v`` is the joint velocity (rad/s). + where ``v`` is the joint velocity (rad/s) and ``τ_gravity`` the gravity + feedforward the joint is carrying. Attributes: - fc: Coulomb friction magnitude (Nm). + fc: Coulomb friction magnitude (Nm) at zero gravity load. k: Tanh sharpness factor — larger is closer to a sign() function. fv: Viscous friction coefficient (Nm·s/rad). fo: Constant friction offset (Nm). Captures direction-independent biases such as imperfect gravity compensation or motor cogging. + fl: Load-proportional Coulomb friction, Nm per Nm of gravity + feedforward. Planetary gear friction grows with the torque the + meshes carry: right shoulder_1's sliding friction measured + ~0.6 Nm at rest and ~1.5 Nm under 12 Nm of load (slope ≈ 0.08), + and its breakaway 0.66 Nm at rest against 2.4–3.3 Nm loaded. A + constant ``fc`` fitted at moderate load therefore over-compensates + at rest (kicking the joint at every reversal) and under-compensates + at reach. ``0`` (default) keeps the constant model; ``tune.friction`` + fits it when its sweep spans enough load. """ fc: float k: float fv: float fo: float + fl: float = 0.0 @dataclass @@ -215,6 +226,19 @@ class JointConfig: reply carries q-axis current, so measured torque reads NaN and the contact watchdog is blind on that joint. Position stays 0.01° via a paired 0x92 read each tick. + stribeck_gain: Friction cancellation on *measured* velocity (see + :func:`almond_axol.robot.control.stribeck_excess`), as a + fraction of the measured static-minus-sliding excess. + ``0`` (default) off. The one feedforward that acts on the + velocity-weakening slope behind the X8-P20 shoulders' + 2 Hz stick-slip; sweep 0.5 → 0.7 → 0.9 and stop when the + arm's 3 Hz mode starts to grow (over-cancellation). + stribeck_dfs: Static-minus-sliding friction excess (Nm) at zero + gravity load; right shoulder_1 measured ~0.3. + stribeck_load_gain: Its growth per Nm of gravity feedforward + (~0.1 on right shoulder_1: excess ≈ 1.5 Nm under 12 Nm). + stribeck_vs: Speed (rad/s) at which the excess has fallen to 1/e + (~0.1 on the X8 shoulders). """ kp: float @@ -232,6 +256,10 @@ class JointConfig: dither_nm: float = 0.0 dither_hz: float = 60.0 wire_mode: str = "mit" + stribeck_gain: float = 0.0 + stribeck_dfs: float = 0.3 + stribeck_load_gain: float = 0.1 + stribeck_vs: float = 0.1 @dataclass @@ -481,6 +509,10 @@ def _calibrated_joint(jc: JointConfig, entry: dict[str, Any]) -> JointConfig: "dither_nm", "dither_hz", "wire_mode", + "stribeck_gain", + "stribeck_dfs", + "stribeck_load_gain", + "stribeck_vs", ) if f in entry } diff --git a/almond_axol/robot/control.py b/almond_axol/robot/control.py index e43a1c34..4c366d8c 100644 --- a/almond_axol/robot/control.py +++ b/almond_axol/robot/control.py @@ -227,6 +227,48 @@ def stiction_compensation( return amp * math.tanh(err / max(err_scale, 1e-9)) * fade +# Speed (rad/s) over which the Stribeck term passes through zero — a hair above +# the measured-velocity noise, so the sign change is smooth and a joint at rest +# gets no push from it (breakaway is the stiction term's job). +STRIBECK_V0 = 0.02 + + +def stribeck_amplitude( + gain: float, dfs: float, load_gain: float, gravity: float +) -> float: + """Excess of low-speed over sliding friction (Nm) this cycle: + ``gain·(dfs + load_gain·|gravity|)``, load-scaled like the stiction push.""" + return gain * (dfs + load_gain * abs(gravity)) + + +def stribeck_excess( + v_meas: float, amp: float, v_s: float, v0: float = STRIBECK_V0 +) -> float: + """Friction cancellation keyed on *measured* velocity: + ``amp·exp(−(v/v_s)²)·tanh(v/v0)``. + + The X8-P20 shoulders' friction falls as they speed up — 2.1 Nm sliding + at 0.05 rad/s, 1.4 at 0.1, 0.7 at 0.2 rad/s under load — and that + negative slope is negative damping: a joint that speeds up sees less + resistance and speeds up more, until the impedance spring catches it and + it slows back into the friction rise and sticks. That is the 2 Hz + stick-slip cycle no command-driven feedforward can stabilise, because a + term computed from the *commanded* velocity does not change when the + real velocity does. + + This term follows the measured velocity with the measured curve's + shape, so when the joint speeds up the feedforward drops by what the real + friction drops and the net slope is flattened; at ``v_s`` (where the + excess has fallen to 1/e) it hands over to the ordinary Coulomb term. + ``gain`` below 1 under-cancels and leaves some cycle; above the true curve + it over-cancels and the net damping goes negative, which shows as the + arm's 3 Hz mode growing. Zero at rest, so it cannot hunt. + """ + if amp == 0.0 or v_s <= 0.0: + return 0.0 + return amp * math.exp(-((v_meas / v_s) ** 2)) * math.tanh(v_meas / max(v0, 1e-9)) + + def dither_step(phase: float, nm: float, hz: float, dt: float) -> tuple[float, float]: """Advance a torque-dither oscillator one step: ``(new_phase, torque)``. diff --git a/almond_axol/rt/link.py b/almond_axol/rt/link.py index 958ab6aa..a330e289 100644 --- a/almond_axol/rt/link.py +++ b/almond_axol/rt/link.py @@ -41,7 +41,7 @@ #: both together whenever the config or target layout changes meaning — a #: package and a binary from different checkouts must fail at configure #: time, not arm and then silently reject every target. -CONFIG_PROTO = 6 +CONFIG_PROTO = 8 def config_header() -> list[str]: diff --git a/almond_axol/rt/robot.py b/almond_axol/rt/robot.py index 815e2fbb..8c1dd838 100644 --- a/almond_axol/rt/robot.py +++ b/almond_axol/rt/robot.py @@ -347,7 +347,9 @@ def _wire_token(mode: str) -> str: f"{f.fc} {f.k} {f.fv} {f.fo} " f"{gains.stiction_gain} {math.radians(gains.stiction_err_deg)} " f"{gains.stiction_load_gain} {gains.dither_nm} {gains.dither_hz} " - f"{_wire_token(gains.wire_mode)}" + f"{_wire_token(gains.wire_mode)} " + f"{gains.stribeck_gain} {gains.stribeck_dfs} " + f"{gains.stribeck_load_gain} {gains.stribeck_vs} {f.fl}" ) if arm._has_gripper: lines.append( diff --git a/almond_axol/serve/app.py b/almond_axol/serve/app.py index 2dc5a97a..fe8357e6 100644 --- a/almond_axol/serve/app.py +++ b/almond_axol/serve/app.py @@ -1968,6 +1968,7 @@ def _load() -> dict[str, Any]: "stiction_gain": jc.stiction_gain, "stiction_load_gain": jc.stiction_load_gain, "dither_nm": jc.dither_nm, + "stribeck_gain": jc.stribeck_gain, } out[side] = joints return out diff --git a/almond_axol/teleop/recorder.py b/almond_axol/teleop/recorder.py index 9a5f60fb..1017797d 100644 --- a/almond_axol/teleop/recorder.py +++ b/almond_axol/teleop/recorder.py @@ -84,6 +84,7 @@ "damping_ff", "stiction_ff", "dither_ff", + "stribeck_ff", "total_ff", "kd_host", "damp_w0", diff --git a/docs/cli/tune-motion.mdx b/docs/cli/tune-motion.mdx index 3bcd61bf..6250421e 100644 --- a/docs/cli/tune-motion.mdx +++ b/docs/cli/tune-motion.mdx @@ -12,7 +12,7 @@ The arm moves to the motion's start and back to rest on collision-aware planned | Flag | Description | |---|---| | `--motion NAME` | Committed motion name (`axol motion.list`) or a path to a motion `.npz` (required) | -| `--gain [SIDE.]JOINT.FIELD=VALUE` | Override one gain for this run, e.g. `left.elbow.kd=4.5` or `shoulder_3.kd_host=8` (no side = both arms). Fields: `kp`, `kd`, `kd_host`, `kd_host_hz`, `kd_host_q`, `j_eff`, `stiction_gain`, `stiction_load_gain`, `stiction_err_deg`, `dither_nm`, `dither_hz`. Repeatable | +| `--gain [SIDE.]JOINT.FIELD=VALUE` | Override one gain for this run, e.g. `left.elbow.kd=4.5` or `shoulder_3.kd_host=8` (no side = both arms). Fields: `kp`, `kd`, `kd_host`, `kd_host_hz`, `kd_host_q`, `j_eff`, `stiction_gain`, `stiction_load_gain`, `stiction_err_deg`, `dither_nm`, `dither_hz`, `stribeck_gain`, `stribeck_dfs`, `stribeck_load_gain`, `stribeck_vs`, and the friction model as `friction.fc`, `friction.k`, `friction.fv`, `friction.fo`, `friction.fl`. Repeatable | | `--stiffness S` | Stiffness-slider position in `[0, 1]` for both arms (default: 1.0, the production default — the tuned gains, where gain overrides land exactly; lower only adds compliance) | | `--ik` | Drive the run through the IK solver: each waypoint's end-effector poses (FK of the reference, with elbow hints) are re-solved to joints exactly like teleop's pose→joints loop, and the arms execute the *solver's* output — still scored against the clean reference, so IK reconstruction error and tracking error show up together. Per-solve times and the solved-vs-reference deviation are stored on the run (`ik_solve_ms_*`, `ik_dev_*`) | | `--noise MODE` | Corrupt the motion before streaming it, at the noise source's real pipeline entry point: `network` (jitter/outliers/stalls), `ik` (solver churn/jumps), `combined`, or `none` (default). Deterministic per `--seed` | diff --git a/docs/snippets/config/robot.mdx b/docs/snippets/config/robot.mdx index f9809fe3..c0505651 100644 --- a/docs/snippets/config/robot.mdx +++ b/docs/snippets/config/robot.mdx @@ -19,6 +19,10 @@ export const F = ({ children }) => {child | --{prefix}left.elbow.stiction_err_deg | `0.1` | Position error (degrees) at which that term saturates. The push fades on *measured* velocity (gone above ~0.05 rad/s), so it never drives the slip. | | --{prefix}left.elbow.dither_nm | `0.0` | Peak amplitude (Nm) of a sinusoidal torque dither on the feed-forward, `0` off. Keeps a geared joint's meshes sliding so its velocity-weakening friction (the X8-P20 shoulders: 2.1 Nm sliding at 0.05 rad/s, 0.7 Nm at 0.2 rad/s) cannot re-stick between cycles. Start at 1–2 Nm on a shoulder; it is audible. | | --{prefix}left.elbow.dither_hz | `60.0` | Dither frequency: above the arm's structural modes (~35 Hz), below the core's 120 Hz Nyquist. | +| --{prefix}left.elbow.stribeck_gain | `0.0` | Friction cancellation on **measured** velocity, as a fraction of the measured static-minus-sliding excess: `gain × (stribeck_dfs + stribeck_load_gain × |gravity|) × exp(−(v/stribeck_vs)²) × tanh(v/0.02)`. The one feed-forward that acts on the velocity-weakening slope behind the X8 shoulders' 2 Hz stick-slip (a command-driven term cannot). Zero at rest. Sweep 0.5 → 0.9 and stop when the arm's 3 Hz mode grows. | +| --{prefix}left.elbow.stribeck_dfs | `0.3` | Static-minus-sliding friction excess (Nm) at zero gravity load. | +| --{prefix}left.elbow.stribeck_load_gain | `0.1` | Growth of that excess per Nm of gravity feed-forward (right shoulder_1: ~1.5 Nm under 12 Nm). | +| --{prefix}left.elbow.stribeck_vs | `0.1` | Speed (rad/s) at which the excess has fallen to 1/e. | | --{prefix}left.elbow.wire_mode | `mit` | Frame the realtime core commands a **MyActuator** joint with while tracking. `mit` is the impedance frame (production). `a4` hands the joint to the firmware's own position loop (0xA4, speed-capped at the tracker limit) with a paired 0x92 read for 0.01° position — the candidate for creeping through X8-P20 stick-slip. Costs: no compliance, no host feed-forward, and NaN torque telemetry (the contact watchdog is blind on that joint). Damiao joints, the gripper, gravity comp and the limp fallback always use MIT. | | --{prefix}left.elbow.mass | per joint | Mass (kg) of the link, used for gravity compensation. Tune if an arm sags or pushes back in [gravity comp](/operations/gravity-comp). | | --{prefix}left.elbow.com | per joint | Centre of mass `[x, y, z]` (m) of the link. | @@ -26,6 +30,7 @@ export const F = ({ children }) => {child | --{prefix}left.elbow.friction.k | per motor | Friction curve sharpness. | | --{prefix}left.elbow.friction.fv | per motor | Viscous friction (Nm·s/rad). | | --{prefix}left.elbow.friction.fo | per motor | Constant torque offset (Nm). | +| --{prefix}left.elbow.friction.fl | `0.0` | Load-proportional Coulomb friction, Nm per Nm of gravity feed-forward: the effective `fc` is `fc + fl × |gravity|`. Planetary gear friction grows with the torque it carries (right shoulder_1: ~0.6 Nm sliding at rest, ~1.5 Nm under 12 Nm), so a constant `fc` over-compensates at rest and under-compensates at reach. `0` keeps the constant model. | Per-arm and per-joint flags are shown for `left` / `elbow`. Swap in `right`, or any joint: `shoulder_1`, `shoulder_2`, `shoulder_3`, `elbow`, `wrist_1`, `wrist_2`, `wrist_3`. diff --git a/rust/axol-rt/src/bringup.rs b/rust/axol-rt/src/bringup.rs index 8998c738..9c8b54c6 100644 --- a/rust/axol-rt/src/bringup.rs +++ b/rust/axol-rt/src/bringup.rs @@ -52,6 +52,15 @@ pub struct MotorSpec { pub dither_hz: f64, /// Command frame for tracked ticks (MyActuator joints only). pub wire: WireMode, + /// Stribeck cancellation on measured velocity (`filter::stribeck_excess`): + /// gain, zero-load excess (Nm), excess per Nm of gravity, 1/e speed. + pub stribeck_gain: f64, + pub stribeck_dfs: f64, + pub stribeck_load_gain: f64, + pub stribeck_vs: f64, + /// Load-proportional Coulomb friction, Nm per Nm of gravity feedforward: + /// the tracked-mode friction term uses `fc + fl·|t_ff|`. + pub fl: f64, } #[derive(Clone, Copy, PartialEq)] @@ -117,6 +126,11 @@ pub struct ReadyMotor { pub dither_nm: f64, pub dither_hz: f64, pub wire: WireMode, + pub stribeck_gain: f64, + pub stribeck_dfs: f64, + pub stribeck_load_gain: f64, + pub stribeck_vs: f64, + pub fl: f64, } /// Status-probe attempts before a silent motor fails the bring-up. @@ -261,6 +275,11 @@ pub fn prepare(sock: &CanSock, iface: &str, specs: &[MotorSpec]) -> io::Result io::Result f64 { amp * (err / err_scale.max(1e-9)).tanh() * fade } +/// Speed (rad/s) over which the Stribeck term passes through zero — +/// `STRIBECK_V0` in `almond_axol.robot.control`. +pub const STRIBECK_V0: f64 = 0.02; + +/// Excess of low-speed over sliding friction this tick, Nm — +/// `stribeck_amplitude` in `almond_axol.robot.control`: +/// `gain·(dfs + load_gain·|gravity|)`. +pub fn stribeck_amplitude(gain: f64, dfs: f64, load_gain: f64, gravity: f64) -> f64 { + gain * (dfs + load_gain * gravity.abs()) +} + +/// Friction cancellation on *measured* velocity — `stribeck_excess` in +/// `almond_axol.robot.control`: `amp·exp(−(v/v_s)²)·tanh(v/STRIBECK_V0)`. +/// Follows the measured velocity with the measured friction curve's shape, +/// so a joint that speeds up sees the feedforward fall by what the real +/// friction falls and the velocity-weakening slope (negative damping — the +/// engine of the 2 Hz stick-slip) is flattened. Zero at rest. +pub fn stribeck_excess(v_meas: f64, amp: f64, v_s: f64) -> f64 { + if amp == 0.0 || v_s <= 0.0 { + return 0.0; + } + amp * (-(v_meas / v_s).powi(2)).exp() * (v_meas / STRIBECK_V0).tanh() +} + /// Per-slot phase offset of the torque dither, the golden angle π(3 − √5) — /// `DITHER_PHASE_STAGGER` in `almond_axol.robot.control`. pub const DITHER_PHASE_STAGGER: f64 = 2.399_963_229_728_653; @@ -627,6 +651,34 @@ mod tests { /// Golden values from `almond_axol.robot.control.compute_friction` /// with fc=0.6, k=250 (above the cap), fv=0.15, fo=0.02. + /// Reference vectors from `almond_axol.robot.control.stribeck_excess` + /// (amp = 1, v_s = 0.1): `(v_meas, want)`. + #[test] + fn stribeck_matches_python() { + let golden = [ + (-0.3, -0.00012340980408665668), + (-0.1, -0.36784603928630505), + (-0.05, -0.7683759879897785), + (-0.02, -0.7317316219624262), + (0.0, 0.0), + (0.01, 0.4575190147179108), + (0.02, 0.7317316219624262), + (0.05, 0.7683759879897785), + (0.1, 0.36784603928630505), + (0.2, 0.018315638813231488), + ]; + for (v, want) in golden { + let got = stribeck_excess(v, 1.0, 0.1); + assert!( + (got - want).abs() < 1e-12, + "stribeck({v}): got {got:e}, want {want:e}" + ); + } + assert_eq!(stribeck_excess(0.05, 0.0, 0.1), 0.0); + assert!((stribeck_amplitude(1.0, 0.3, 0.1, -12.0) - 1.5).abs() < 1e-12); + assert_eq!(stribeck_amplitude(0.0, 0.3, 0.1, 12.0), 0.0); + } + /// Reference vectors from `almond_axol.robot.control.dither_step`: /// 1.5 Nm at 60 Hz stepped at 240 Hz, slots 0 and 1. #[test] diff --git a/rust/axol-rt/src/hold.rs b/rust/axol-rt/src/hold.rs index ee63d6a4..95372509 100644 --- a/rust/axol-rt/src/hold.rs +++ b/rust/axol-rt/src/hold.rs @@ -79,6 +79,11 @@ pub fn parse_params(path: &str) -> io::Result> { dither_nm: 0.0, dither_hz: 0.0, wire: WireMode::Mit, + stribeck_gain: 0.0, + stribeck_dfs: 0.0, + stribeck_load_gain: 0.0, + stribeck_vs: 0.0, + fl: 0.0, }, t_ff: fields.get(5)?.parse().ok()?, }) diff --git a/rust/axol-rt/src/serve.rs b/rust/axol-rt/src/serve.rs index 2c29f0b0..44867b29 100644 --- a/rust/axol-rt/src/serve.rs +++ b/rust/axol-rt/src/serve.rs @@ -243,7 +243,9 @@ const HOLDOVER_MAX: f64 = 0.080; /// - 4: plus the load-proportional stiction gain (`filter::stiction_amplitude`). /// - 5: plus the torque dither amplitude and frequency (`filter::dither_step`). /// - 6: plus the wire mode token (`mit` | `a4`, `bringup::WireMode`). -const CONFIG_PROTO: u32 = 6; +/// - 7: plus the four Stribeck cancellation fields (`filter::stribeck_excess`). +/// - 8: plus the load-proportional Coulomb friction `fl` (Nm per Nm of gravity). +const CONFIG_PROTO: u32 = 8; /// Rolling feedback loss at or above this many misses in the last 32 ticks /// (12.5% over 133 ms at 240 Hz) marks a joint *degraded*: its host damping /// stays off until a full clean window has passed, and the transition is @@ -634,6 +636,7 @@ struct TraceRow { damping_ff: f64, stiction_ff: f64, dither_ff: f64, + stribeck_ff: f64, total_ff: f64, kd_host: f64, damp_w0: f64, @@ -655,7 +658,7 @@ fn trace_file(path: &PathBuf) -> io::Result> { let mut out = io::BufWriter::new(std::fs::File::create(path)?); writeln!( out, - "tick,time_s,seq,slot,motor_id,mode,target_p,cmd_p,cmd_v,cmd_a,cmd_v_fast,meas_p,motor_v,meas_v,meas_tau,gravity_ff,friction_ff,inertia_ff,damping_ff,stiction_ff,dither_ff,total_ff,kd_host,damp_w0,damp_q,tick_dt,fb_dt" + "tick,time_s,seq,slot,motor_id,mode,target_p,cmd_p,cmd_v,cmd_a,cmd_v_fast,meas_p,motor_v,meas_v,meas_tau,gravity_ff,friction_ff,inertia_ff,damping_ff,stiction_ff,dither_ff,stribeck_ff,total_ff,kd_host,damp_w0,damp_q,tick_dt,fb_dt" )?; Ok(out) } @@ -663,7 +666,7 @@ fn trace_file(path: &PathBuf) -> io::Result> { fn write_trace_row(out: &mut io::BufWriter, r: TraceRow) -> io::Result<()> { writeln!( out, - "{},{:.9},{},{},{},{:.1},{:.12},{:.12},{:.12},{:.12},{:.12},{:.12},{:.12},{:.12},{:.12},{:.12},{:.12},{:.12},{:.12},{:.12},{:.12},{:.12},{:.12},{:.12},{:.12},{:.9},{:.9}", + "{},{:.9},{},{},{},{:.1},{:.12},{:.12},{:.12},{:.12},{:.12},{:.12},{:.12},{:.12},{:.12},{:.12},{:.12},{:.12},{:.12},{:.12},{:.12},{:.12},{:.12},{:.12},{:.12},{:.12},{:.9},{:.9}", r.tick, r.time_s, r.seq, @@ -685,6 +688,7 @@ fn write_trace_row(out: &mut io::BufWriter, r: TraceRow) -> io::R r.damping_ff, r.stiction_ff, r.dither_ff, + r.stribeck_ff, r.total_ff, r.kd_host, r.damp_w0, @@ -896,6 +900,8 @@ fn parse_config(text: &str) -> io::Result { // // // + // + // // gripper let gripper = f[0] == "gripper"; let side: u8 = f @@ -938,6 +944,11 @@ fn parse_config(text: &str) -> io::Result { dither_nm: 0.0, dither_hz: 0.0, wire: WireMode::Mit, + stribeck_gain: 0.0, + stribeck_dfs: 0.0, + stribeck_load_gain: 0.0, + stribeck_vs: 0.0, + fl: 0.0, } } else { let motor_id: u8 = f @@ -974,6 +985,11 @@ fn parse_config(text: &str) -> io::Result { .get(18) .and_then(|t| WireMode::parse(t)) .ok_or_else(|| bad(line))?, + stribeck_gain: num(19)?, + stribeck_dfs: num(20)?, + stribeck_load_gain: num(21)?, + stribeck_vs: num(22)?, + fl: num(23)?, } }; if spec.slot >= N_SLOTS || bus.2.iter().any(|s| s.slot == spec.slot) { @@ -1382,12 +1398,12 @@ mod tests { #[test] fn parse_config_assigns_slots() { let cfg = parse_config( - "proto 6\n\ + "proto 8\n\ loop_hz 240\n\ - joint 0 canL shoulder_1 1 250 3.5 9.4 33.0 0.6 250 0.15 0.02 0 0 0 0 60 mit\n\ - joint 0 canL shoulder_2 2 250 3.5 9.4 33.0 0.5 250 0.10 0.0 0 0 0 0 60 mit\n\ + joint 0 canL shoulder_1 1 250 3.5 9.4 33.0 0.6 250 0.15 0.02 0 0 0 0 60 mit 0 0.3 0.1 0.1 0\n\ + joint 0 canL shoulder_2 2 250 3.5 9.4 33.0 0.5 250 0.10 0.0 0 0 0 0 60 mit 0 0.3 0.1 0.1 0\n\ gripper 0 canL 8\n\ - joint 0 canL shoulder_3 3 180 2.0 9.4 33.0 0.4 250 0.08 0.0 0.6 0.0017 0.2 1.5 60 a4\n", + joint 0 canL shoulder_3 3 180 2.0 9.4 33.0 0.4 250 0.08 0.0 0.6 0.0017 0.2 1.5 60 a4 0.7 0.3 0.1 0.1 0.08\n", ) .unwrap(); let specs = &cfg.buses[0].2; @@ -1417,29 +1433,48 @@ mod tests { (specs[0].wire, specs[3].wire), (WireMode::Mit, WireMode::A4) ); + assert_eq!(specs[0].stribeck_gain, 0.0); + assert_eq!( + ( + specs[3].stribeck_gain, + specs[3].stribeck_dfs, + specs[3].stribeck_load_gain, + specs[3].stribeck_vs + ), + (0.7, 0.3, 0.1, 0.1) + ); + assert_eq!((specs[0].fl, specs[3].fl), (0.0, 0.08)); // An unknown wire token is a bad line, not a silent MIT. assert!(parse_config( - "proto 6\njoint 0 canL shoulder_1 1 250 3.5 9.4 33.0 0.6 250 0.15 0.02 0 0 0 0 60 a9\n" + "proto 8\njoint 0 canL shoulder_1 1 250 3.5 9.4 33.0 0.6 250 0.15 0.02 0 0 0 0 60 a9 0 0.3 0.1 0.1 0\n" ) .is_err()); // A joint line missing the tracker/friction params (the previous // 7-field layout) must be rejected, not defaulted. - assert!(parse_config("proto 6\njoint 0 canL shoulder_1 1 250 3.5\n").is_err()); - // ... and so must the proto-2/3/4/5 layouts (13, 15, 16 or 18 fields). + assert!(parse_config("proto 8\njoint 0 canL shoulder_1 1 250 3.5\n").is_err()); + // ... and so must the proto-2/3/4/5/6/7 layouts (13 … 23 fields). + assert!(parse_config( + "proto 8\njoint 0 canL shoulder_1 1 250 3.5 9.4 33.0 0.6 250 0.15 0.02\n" + ) + .is_err()); assert!(parse_config( - "proto 6\njoint 0 canL shoulder_1 1 250 3.5 9.4 33.0 0.6 250 0.15 0.02\n" + "proto 8\njoint 0 canL shoulder_1 1 250 3.5 9.4 33.0 0.6 250 0.15 0.02 0 0\n" ) .is_err()); assert!(parse_config( - "proto 6\njoint 0 canL shoulder_1 1 250 3.5 9.4 33.0 0.6 250 0.15 0.02 0 0\n" + "proto 8\njoint 0 canL shoulder_1 1 250 3.5 9.4 33.0 0.6 250 0.15 0.02 0 0 0\n" ) .is_err()); assert!(parse_config( - "proto 6\njoint 0 canL shoulder_1 1 250 3.5 9.4 33.0 0.6 250 0.15 0.02 0 0 0\n" + "proto 8\njoint 0 canL shoulder_1 1 250 3.5 9.4 33.0 0.6 250 0.15 0.02 0 0 0 0 60\n" ) .is_err()); assert!(parse_config( - "proto 6\njoint 0 canL shoulder_1 1 250 3.5 9.4 33.0 0.6 250 0.15 0.02 0 0 0 0 60\n" + "proto 8\njoint 0 canL shoulder_1 1 250 3.5 9.4 33.0 0.6 250 0.15 0.02 0 0 0 0 60 mit\n" + ) + .is_err()); + assert!(parse_config( + "proto 8\njoint 0 canL shoulder_1 1 250 3.5 9.4 33.0 0.6 250 0.15 0.02 0 0 0 0 60 mit 0 0.3 0.1 0.1\n" ) .is_err()); } @@ -1450,9 +1485,9 @@ mod tests { #[test] fn parse_config_subset_keeps_joint_slots() { let cfg = parse_config( - "proto 6\n\ - joint 0 can0 wrist_2 6 40 1.0 9.4 33.0 0.0 0.0 0.0 0.0 0 0 0 0 60 mit\n\ - joint 0 can0 wrist_3 7 40 1.0 9.4 33.0 0.0 0.0 0.0 0.0 0 0 0 0 60 mit\n\ + "proto 8\n\ + joint 0 can0 wrist_2 6 40 1.0 9.4 33.0 0.0 0.0 0.0 0.0 0 0 0 0 60 mit 0 0.3 0.1 0.1 0\n\ + joint 0 can0 wrist_3 7 40 1.0 9.4 33.0 0.0 0.0 0.0 0.0 0 0 0 0 60 mit 0 0.3 0.1 0.1 0\n\ gripper 0 can0 8\n", ) .unwrap(); @@ -1464,17 +1499,17 @@ mod tests { // Arm joint ids outside 1..=7 have no slot; a repeated id would // double-book one. assert!(parse_config( - "proto 6\njoint 0 can0 wrist_3 8 40 1.0 9.4 33.0 0 0 0 0 0 0 0 0 60 mit\n" + "proto 8\njoint 0 can0 wrist_3 8 40 1.0 9.4 33.0 0 0 0 0 0 0 0 0 60 mit 0 0.3 0.1 0.1 0\n" ) .is_err()); assert!(parse_config( - "proto 6\njoint 0 can0 bogus 0 40 1.0 9.4 33.0 0 0 0 0 0 0 0 0 60 mit\n" + "proto 8\njoint 0 can0 bogus 0 40 1.0 9.4 33.0 0 0 0 0 0 0 0 0 60 mit 0 0.3 0.1 0.1 0\n" ) .is_err()); assert!(parse_config( - "proto 6\n\ - joint 0 can0 wrist_2 6 40 1.0 9.4 33.0 0 0 0 0 0 0 0 0 60 mit\n\ - joint 0 can0 wrist_2 6 40 1.0 9.4 33.0 0 0 0 0 0 0 0 0 60 mit\n" + "proto 8\n\ + joint 0 can0 wrist_2 6 40 1.0 9.4 33.0 0 0 0 0 0 0 0 0 60 mit 0 0.3 0.1 0.1 0\n\ + joint 0 can0 wrist_2 6 40 1.0 9.4 33.0 0 0 0 0 0 0 0 0 60 mit 0 0.3 0.1 0.1 0\n" ) .is_err()); } @@ -1485,7 +1520,8 @@ mod tests { /// target on the max-step gate — the arms enabled and never moved. #[test] fn parse_config_requires_matching_proto() { - let joint = "joint 0 can0 wrist_2 6 40 1.0 9.4 33.0 0 0 0 0 0 0 0 0 60 mit\n"; + let joint = + "joint 0 can0 wrist_2 6 40 1.0 9.4 33.0 0 0 0 0 0 0 0 0 60 mit 0 0.3 0.1 0.1 0\n"; let error_of = |text: &str| match parse_config(text) { Ok(_) => panic!("accepted a skewed config: {text:?}"), Err(err) => err.to_string(), @@ -1497,12 +1533,12 @@ mod tests { // A future client generation this core does not understand. let err = error_of(&format!("proto 99\n{joint}")); assert!(err.contains("proto 99"), "{err}"); - assert!(err.contains("proto 6"), "{err}"); + assert!(err.contains("proto 8"), "{err}"); // Malformed declarations are bad lines, not silently accepted. assert!(parse_config(&format!("proto\n{joint}")).is_err()); assert!(parse_config(&format!("proto two\n{joint}")).is_err()); // Order does not matter; the line just has to be there. - assert!(parse_config(&format!("{joint}proto 6\n")).is_ok()); + assert!(parse_config(&format!("{joint}proto 8\n")).is_ok()); } } @@ -2004,6 +2040,11 @@ fn bus_loop( v_meas: LpDiff, bp: BandPass, vel_meas: f64, + /// Slower (20 rad/s) measured-velocity estimate for the Stribeck + /// term: smooth enough at 0.05 rad/s that the feedforward does not + /// step with the encoder, ~30° behind at the 2 Hz cycle. + v_meas_slow: LpDiff, + vel_meas_slow: f64, last_fb: Option, /// Torque-dither oscillator phase (`filter::dither_step`), started a /// golden angle apart per slot. @@ -2017,6 +2058,8 @@ fn bus_loop( v_meas: LpDiff::new(VEL_CUTOFF), bp: BandPass::new(), vel_meas: 0.0, + v_meas_slow: LpDiff::new(CONTROL_CUTOFF), + vel_meas_slow: 0.0, last_fb: None, dither_phase: slot as f64 * filter::DITHER_PHASE_STAGGER, }) @@ -2428,6 +2471,7 @@ fn bus_loop( v_damp, stiction_ff, dither_ff, + stribeck_ff, ) = if tracked && overrun { // The gap since the last command is not a trajectory // segment the motor followed — it held. Re-prime the @@ -2439,7 +2483,7 @@ fn bus_loop( d.a_cmd.seed(0.0); d.v_cmd_fast.seed(p_cmd); d.bp.reset(); - (0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0) + (0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0) } else if tracked { // Match classic AxolArm.motion_control: friction uses // the 20 rad/s low-pass position derivative, inertia @@ -2450,7 +2494,10 @@ fn bus_loop( let v_cmd = d.v_cmd.update(p_cmd, tick_dt); let a_cmd = d.a_cmd.update(v_cmd, tick_dt); let v_cmd_fast = d.v_cmd_fast.update(p_cmd, tick_dt); - let friction_ff = filter::friction(v_cmd, m.fc, m.k, m.fv, m.fo); + // Sliding friction grows with the torque the gear + // meshes carry (`fl`, Nm per Nm of gravity). + let fc_eff = m.fc + m.fl * c.t_ff.abs(); + let friction_ff = filter::friction(v_cmd, fc_eff, m.k, m.fv, m.fo); // Stiction compensation acts on the measured error // against the latest accepted feedback. It is a slow // term (it resolves a stuck joint over tens of ms), @@ -2477,6 +2524,23 @@ fn bus_loop( m.dither_hz, tick_dt, ); + // Stribeck cancellation on the measured velocity — + // only with a fresh sample behind it, like the + // stiction push. + let stribeck_ff = if feedback_fresh[m.slot] { + filter::stribeck_excess( + d.vel_meas_slow, + filter::stribeck_amplitude( + m.stribeck_gain, + m.stribeck_dfs, + m.stribeck_load_gain, + c.t_ff, + ), + m.stribeck_vs, + ) + } else { + 0.0 + }; let inertia_ff = c.j_eff * a_cmd; let damp_ok = feedback_fresh[m.slot] && timing_on_time @@ -2503,17 +2567,23 @@ fn bus_loop( v_damp, stiction_ff, dither_ff, + stribeck_ff, ) } else { d.v_cmd.seed(p_cmd); d.a_cmd.seed(0.0); d.v_cmd_fast.seed(p_cmd); d.bp.reset(); - (0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0) + (0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0) }; let damping_ff = c.kd_host * v_damp; - let t_ff = - c.t_ff + friction_ff + stiction_ff + dither_ff + inertia_ff + damping_ff; + let t_ff = c.t_ff + + friction_ff + + stiction_ff + + dither_ff + + stribeck_ff + + inertia_ff + + damping_ff; if trace_this_tick && trace_tx.is_some() { trace_pending[m.slot] = Some(TraceRow { tick: ticks, @@ -2538,6 +2608,7 @@ fn bus_loop( damping_ff, stiction_ff, dither_ff, + stribeck_ff, total_ff: t_ff, kd_host: c.kd_host, damp_w0: c.damp_w0, @@ -2723,6 +2794,7 @@ fn bus_loop( .map_or(0.0, |p| recv_time.duration_since(p).as_secs_f64()); d.last_fb = Some(recv_time); d.vel_meas = d.v_meas.update(pos, dt); + d.vel_meas_slow = d.v_meas_slow.update(pos, dt); (d.vel_meas, dt) }; if let (Some(tx), Some(mut row)) = diff --git a/tests/test_rom_partial_arm.py b/tests/test_rom_partial_arm.py index 0241ce2c..0e2c0a2b 100644 --- a/tests/test_rom_partial_arm.py +++ b/tests/test_rom_partial_arm.py @@ -402,7 +402,19 @@ async def test_bench_arm_streams_soft_pd_and_zero_feedforward(self) -> None: # every joint is on the MIT frame. self.assertEqual( line.split()[13:], - ["0.0", "0.0017453292519943296", "0.0", "0.0", "60.0", "mit"], + [ + "0.0", + "0.0017453292519943296", + "0.0", + "0.0", + "60.0", + "mit", + "0.0", + "0.3", + "0.1", + "0.1", + "0.0", + ], line, ) diff --git a/tests/test_rt_link.py b/tests/test_rt_link.py index d60979e3..397798f7 100644 --- a/tests/test_rt_link.py +++ b/tests/test_rt_link.py @@ -100,9 +100,10 @@ def _link(self, proc) -> link.RtLink: def test_config_header_declares_the_protocol(self) -> None: self.assertEqual(link.config_header(), [f"proto {link.CONFIG_PROTO}"]) # 2: slot-by-motor-id; 3/4: stiction fields; 5: dither fields; 6: wire - # mode token on every joint line. Bump both sides together + # mode token; 7: Stribeck fields; 8: load-proportional friction fl on + # every joint line. Bump both sides together # (rust/axol-rt/src/serve.rs CONFIG_PROTO). - self.assertEqual(link.CONFIG_PROTO, 6) + self.assertEqual(link.CONFIG_PROTO, 8) async def test_configure_names_a_stale_binary_when_the_core_exits(self) -> None: rt = self._link(_ExitedProc()) diff --git a/tests/test_stiction.py b/tests/test_stiction.py index d6c115f5..b2e7dfd2 100644 --- a/tests/test_stiction.py +++ b/tests/test_stiction.py @@ -26,6 +26,8 @@ dither_step, stiction_amplitude, stiction_compensation, + stribeck_amplitude, + stribeck_excess, ) _SCALE = math.radians(0.1) @@ -119,6 +121,63 @@ def test_coulomb_unit_matches_compute_friction(self) -> None: ) +# (v_meas rad/s, want) for amp = 1, v_s = 0.1, v0 = 0.02. +_STRIBECK_GOLDEN = [ + (-0.3, -0.00012340980408665668), + (-0.1, -0.36784603928630505), + (-0.05, -0.7683759879897785), + (-0.02, -0.7317316219624262), + (0.0, 0.0), + (0.01, 0.4575190147179108), + (0.02, 0.7317316219624262), + (0.05, 0.7683759879897785), + (0.1, 0.36784603928630505), + (0.2, 0.018315638813231488), +] + + +class StribeckTest(unittest.TestCase): + def test_golden_vectors_shared_with_the_core(self) -> None: + for v, want in _STRIBECK_GOLDEN: + self.assertAlmostEqual( + stribeck_excess(v, 1.0, 0.1), want, places=12, msg=f"v={v}" + ) + + def test_zero_at_rest_and_off_by_default(self) -> None: + self.assertEqual(stribeck_excess(0.0, 1.0, 0.1), 0.0) + self.assertEqual(stribeck_excess(0.05, 0.0, 0.1), 0.0) + self.assertEqual(stribeck_amplitude(0.0, 0.3, 0.1, 12.0), 0.0) + + def test_has_the_measured_shape(self) -> None: + # Excess peaks in the creep band and is gone by 3·v_s; the slope + # between 0.05 and 0.2 rad/s is negative, like the measured curve. + amp = stribeck_amplitude(1.0, 0.3, 0.1, 12.0) # 1.5 Nm under 12 Nm of load + self.assertAlmostEqual(amp, 1.5) + creep = stribeck_excess(0.05, amp, 0.1) + fast = stribeck_excess(0.2, amp, 0.1) + self.assertGreater(creep, 1.0) + self.assertLess(fast, 0.05) + self.assertLess(stribeck_excess(-0.05, amp, 0.1), 0.0) + + +class LoadFrictionTest(unittest.TestCase): + def test_fl_defaults_to_zero_and_loads_from_calibration(self) -> None: + from almond_axol.robot.config import FrictionParams + + self.assertEqual(FrictionParams(fc=0.6, k=100.0, fv=0.0, fo=0.0).fl, 0.0) + out = _calibrated_joint( + ArmConfig().shoulder_1, + {"friction": {"fc": 0.6, "k": 100.0, "fv": 0.0, "fo": 0.0, "fl": 0.08}}, + ) + self.assertEqual((out.friction.fc, out.friction.fl), (0.6, 0.08)) + # An older calibration file without fl still loads. + out = _calibrated_joint( + ArmConfig().shoulder_1, + {"friction": {"fc": 0.6, "k": 100.0, "fv": 0.0, "fo": 0.0}}, + ) + self.assertEqual(out.friction.fl, 0.0) + + class StictionConfigTest(unittest.TestCase): def test_defaults_are_off_on_every_joint(self) -> None: arm = ArmConfig() @@ -136,6 +195,7 @@ def test_defaults_are_off_on_every_joint(self) -> None: self.assertEqual(jc.stiction_err_deg, 0.1, name) self.assertEqual(jc.dither_nm, 0.0, name) self.assertEqual(jc.wire_mode, "mit", name) + self.assertEqual(jc.stribeck_gain, 0.0, name) def test_calibration_file_can_set_the_fields(self) -> None: base = ArmConfig().shoulder_1 diff --git a/web/app/src/components/diagnostics/tuning-workbench.tsx b/web/app/src/components/diagnostics/tuning-workbench.tsx index 7a42a81b..e273ee2f 100644 --- a/web/app/src/components/diagnostics/tuning-workbench.tsx +++ b/web/app/src/components/diagnostics/tuning-workbench.tsx @@ -677,6 +677,7 @@ const OVERRIDE_FIELDS = [ "stiction_gain", "stiction_load_gain", "dither_nm", + "stribeck_gain", ] /** Format a config gain for seeding/comparison (trims float32 noise). */ From 5d2ebd9f1bc9bb441f44a8276b165ffa511a03d3 Mon Sep 17 00:00:00 2001 From: Shawn Patel Date: Fri, 18 Sep 2026 19:46:43 -0700 Subject: [PATCH 09/80] stribeck_pole: per-joint measured-velocity pole for the Stribeck term (proto 9) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first A/B (gains 0.5/0.7 on the right shoulders) left velocity ripple unchanged: the term arrived ~40 ms behind the 2.6 Hz surge through its fixed 20 rad/s velocity filter and cancelled only ~2.6 of the ~7 Nm·s/rad friction slope. The pole is now a per-joint setting so lag can be traded against encoder-step noise (40-80 rad/s) on the same replay. Co-Authored-By: Claude Fable 5.1 --- almond_axol/cli/tune/motion.py | 1 + almond_axol/robot/config.py | 8 ++++ almond_axol/rt/link.py | 2 +- almond_axol/rt/robot.py | 3 +- docs/cli/tune-motion.mdx | 2 +- docs/snippets/config/robot.mdx | 1 + rust/axol-rt/src/bringup.rs | 6 +++ rust/axol-rt/src/hold.rs | 1 + rust/axol-rt/src/serve.rs | 71 +++++++++++++++++++++------------- tests/test_rom_partial_arm.py | 1 + tests/test_rt_link.py | 8 ++-- 11 files changed, 70 insertions(+), 34 deletions(-) diff --git a/almond_axol/cli/tune/motion.py b/almond_axol/cli/tune/motion.py index 308b990e..0004a523 100644 --- a/almond_axol/cli/tune/motion.py +++ b/almond_axol/cli/tune/motion.py @@ -76,6 +76,7 @@ "stribeck_dfs", "stribeck_load_gain", "stribeck_vs", + "stribeck_pole", # Friction model, addressed as ``joint.friction.fc`` etc. — the sliding # friction feedforward is the other half of every stick-slip A/B. "friction.fc", diff --git a/almond_axol/robot/config.py b/almond_axol/robot/config.py index 09ec25ec..8bfd6018 100644 --- a/almond_axol/robot/config.py +++ b/almond_axol/robot/config.py @@ -239,6 +239,12 @@ class JointConfig: (~0.1 on right shoulder_1: excess ≈ 1.5 Nm under 12 Nm). stribeck_vs: Speed (rad/s) at which the excess has fallen to 1/e (~0.1 on the X8 shoulders). + stribeck_pole: Low-pass pole (rad/s) of the measured-velocity + estimate the term follows. 20 rad/s (3.2 Hz) is smooth at + 0.05 rad/s but ~40° behind the 2.6 Hz ring, which halved + the cancellation in the first A/B; 40-80 rad/s follows the + surge more closely at the cost of encoder-step noise in + the torque (a 16-bit count at 240 Hz is 0.09 rad/s). """ kp: float @@ -260,6 +266,7 @@ class JointConfig: stribeck_dfs: float = 0.3 stribeck_load_gain: float = 0.1 stribeck_vs: float = 0.1 + stribeck_pole: float = 20.0 @dataclass @@ -513,6 +520,7 @@ def _calibrated_joint(jc: JointConfig, entry: dict[str, Any]) -> JointConfig: "stribeck_dfs", "stribeck_load_gain", "stribeck_vs", + "stribeck_pole", ) if f in entry } diff --git a/almond_axol/rt/link.py b/almond_axol/rt/link.py index a330e289..852244a9 100644 --- a/almond_axol/rt/link.py +++ b/almond_axol/rt/link.py @@ -41,7 +41,7 @@ #: both together whenever the config or target layout changes meaning — a #: package and a binary from different checkouts must fail at configure #: time, not arm and then silently reject every target. -CONFIG_PROTO = 8 +CONFIG_PROTO = 9 def config_header() -> list[str]: diff --git a/almond_axol/rt/robot.py b/almond_axol/rt/robot.py index 8c1dd838..db0fb828 100644 --- a/almond_axol/rt/robot.py +++ b/almond_axol/rt/robot.py @@ -349,7 +349,8 @@ def _wire_token(mode: str) -> str: f"{gains.stiction_load_gain} {gains.dither_nm} {gains.dither_hz} " f"{_wire_token(gains.wire_mode)} " f"{gains.stribeck_gain} {gains.stribeck_dfs} " - f"{gains.stribeck_load_gain} {gains.stribeck_vs} {f.fl}" + f"{gains.stribeck_load_gain} {gains.stribeck_vs} {f.fl} " + f"{gains.stribeck_pole}" ) if arm._has_gripper: lines.append( diff --git a/docs/cli/tune-motion.mdx b/docs/cli/tune-motion.mdx index 6250421e..eafeb07b 100644 --- a/docs/cli/tune-motion.mdx +++ b/docs/cli/tune-motion.mdx @@ -12,7 +12,7 @@ The arm moves to the motion's start and back to rest on collision-aware planned | Flag | Description | |---|---| | `--motion NAME` | Committed motion name (`axol motion.list`) or a path to a motion `.npz` (required) | -| `--gain [SIDE.]JOINT.FIELD=VALUE` | Override one gain for this run, e.g. `left.elbow.kd=4.5` or `shoulder_3.kd_host=8` (no side = both arms). Fields: `kp`, `kd`, `kd_host`, `kd_host_hz`, `kd_host_q`, `j_eff`, `stiction_gain`, `stiction_load_gain`, `stiction_err_deg`, `dither_nm`, `dither_hz`, `stribeck_gain`, `stribeck_dfs`, `stribeck_load_gain`, `stribeck_vs`, and the friction model as `friction.fc`, `friction.k`, `friction.fv`, `friction.fo`, `friction.fl`. Repeatable | +| `--gain [SIDE.]JOINT.FIELD=VALUE` | Override one gain for this run, e.g. `left.elbow.kd=4.5` or `shoulder_3.kd_host=8` (no side = both arms). Fields: `kp`, `kd`, `kd_host`, `kd_host_hz`, `kd_host_q`, `j_eff`, `stiction_gain`, `stiction_load_gain`, `stiction_err_deg`, `dither_nm`, `dither_hz`, `stribeck_gain`, `stribeck_dfs`, `stribeck_load_gain`, `stribeck_vs`, `stribeck_pole`, and the friction model as `friction.fc`, `friction.k`, `friction.fv`, `friction.fo`, `friction.fl`. Repeatable | | `--stiffness S` | Stiffness-slider position in `[0, 1]` for both arms (default: 1.0, the production default — the tuned gains, where gain overrides land exactly; lower only adds compliance) | | `--ik` | Drive the run through the IK solver: each waypoint's end-effector poses (FK of the reference, with elbow hints) are re-solved to joints exactly like teleop's pose→joints loop, and the arms execute the *solver's* output — still scored against the clean reference, so IK reconstruction error and tracking error show up together. Per-solve times and the solved-vs-reference deviation are stored on the run (`ik_solve_ms_*`, `ik_dev_*`) | | `--noise MODE` | Corrupt the motion before streaming it, at the noise source's real pipeline entry point: `network` (jitter/outliers/stalls), `ik` (solver churn/jumps), `combined`, or `none` (default). Deterministic per `--seed` | diff --git a/docs/snippets/config/robot.mdx b/docs/snippets/config/robot.mdx index c0505651..8665328e 100644 --- a/docs/snippets/config/robot.mdx +++ b/docs/snippets/config/robot.mdx @@ -23,6 +23,7 @@ export const F = ({ children }) => {child | --{prefix}left.elbow.stribeck_dfs | `0.3` | Static-minus-sliding friction excess (Nm) at zero gravity load. | | --{prefix}left.elbow.stribeck_load_gain | `0.1` | Growth of that excess per Nm of gravity feed-forward (right shoulder_1: ~1.5 Nm under 12 Nm). | | --{prefix}left.elbow.stribeck_vs | `0.1` | Speed (rad/s) at which the excess has fallen to 1/e. | +| --{prefix}left.elbow.stribeck_pole | `20.0` | Low-pass pole (rad/s) of the measured velocity the term follows. 20 is smooth but ~40° behind a 2.6 Hz surge; 40–80 follows it at the cost of encoder-step noise in the torque. | | --{prefix}left.elbow.wire_mode | `mit` | Frame the realtime core commands a **MyActuator** joint with while tracking. `mit` is the impedance frame (production). `a4` hands the joint to the firmware's own position loop (0xA4, speed-capped at the tracker limit) with a paired 0x92 read for 0.01° position — the candidate for creeping through X8-P20 stick-slip. Costs: no compliance, no host feed-forward, and NaN torque telemetry (the contact watchdog is blind on that joint). Damiao joints, the gripper, gravity comp and the limp fallback always use MIT. | | --{prefix}left.elbow.mass | per joint | Mass (kg) of the link, used for gravity compensation. Tune if an arm sags or pushes back in [gravity comp](/operations/gravity-comp). | | --{prefix}left.elbow.com | per joint | Centre of mass `[x, y, z]` (m) of the link. | diff --git a/rust/axol-rt/src/bringup.rs b/rust/axol-rt/src/bringup.rs index 9c8b54c6..5fb42bd6 100644 --- a/rust/axol-rt/src/bringup.rs +++ b/rust/axol-rt/src/bringup.rs @@ -61,6 +61,9 @@ pub struct MotorSpec { /// Load-proportional Coulomb friction, Nm per Nm of gravity feedforward: /// the tracked-mode friction term uses `fc + fl·|t_ff|`. pub fl: f64, + /// Low-pass pole (rad/s) of the measured velocity the Stribeck term + /// follows; `<= 0` falls back to the control derivative pole. + pub stribeck_pole: f64, } #[derive(Clone, Copy, PartialEq)] @@ -131,6 +134,7 @@ pub struct ReadyMotor { pub stribeck_load_gain: f64, pub stribeck_vs: f64, pub fl: f64, + pub stribeck_pole: f64, } /// Status-probe attempts before a silent motor fails the bring-up. @@ -280,6 +284,7 @@ pub fn prepare(sock: &CanSock, iface: &str, specs: &[MotorSpec]) -> io::Result io::Result io::Result> { stribeck_load_gain: 0.0, stribeck_vs: 0.0, fl: 0.0, + stribeck_pole: 0.0, }, t_ff: fields.get(5)?.parse().ok()?, }) diff --git a/rust/axol-rt/src/serve.rs b/rust/axol-rt/src/serve.rs index 44867b29..c386faba 100644 --- a/rust/axol-rt/src/serve.rs +++ b/rust/axol-rt/src/serve.rs @@ -245,7 +245,8 @@ const HOLDOVER_MAX: f64 = 0.080; /// - 6: plus the wire mode token (`mit` | `a4`, `bringup::WireMode`). /// - 7: plus the four Stribeck cancellation fields (`filter::stribeck_excess`). /// - 8: plus the load-proportional Coulomb friction `fl` (Nm per Nm of gravity). -const CONFIG_PROTO: u32 = 8; +/// - 9: plus the Stribeck term's measured-velocity pole (rad/s). +const CONFIG_PROTO: u32 = 9; /// Rolling feedback loss at or above this many misses in the last 32 ticks /// (12.5% over 133 ms at 240 Hz) marks a joint *degraded*: its host damping /// stays off until a full clean window has passed, and the transition is @@ -901,7 +902,7 @@ fn parse_config(text: &str) -> io::Result { // // // - // + // // gripper let gripper = f[0] == "gripper"; let side: u8 = f @@ -949,6 +950,7 @@ fn parse_config(text: &str) -> io::Result { stribeck_load_gain: 0.0, stribeck_vs: 0.0, fl: 0.0, + stribeck_pole: 0.0, } } else { let motor_id: u8 = f @@ -990,6 +992,7 @@ fn parse_config(text: &str) -> io::Result { stribeck_load_gain: num(21)?, stribeck_vs: num(22)?, fl: num(23)?, + stribeck_pole: num(24)?, } }; if spec.slot >= N_SLOTS || bus.2.iter().any(|s| s.slot == spec.slot) { @@ -1398,12 +1401,12 @@ mod tests { #[test] fn parse_config_assigns_slots() { let cfg = parse_config( - "proto 8\n\ + "proto 9\n\ loop_hz 240\n\ - joint 0 canL shoulder_1 1 250 3.5 9.4 33.0 0.6 250 0.15 0.02 0 0 0 0 60 mit 0 0.3 0.1 0.1 0\n\ - joint 0 canL shoulder_2 2 250 3.5 9.4 33.0 0.5 250 0.10 0.0 0 0 0 0 60 mit 0 0.3 0.1 0.1 0\n\ + joint 0 canL shoulder_1 1 250 3.5 9.4 33.0 0.6 250 0.15 0.02 0 0 0 0 60 mit 0 0.3 0.1 0.1 0 20\n\ + joint 0 canL shoulder_2 2 250 3.5 9.4 33.0 0.5 250 0.10 0.0 0 0 0 0 60 mit 0 0.3 0.1 0.1 0 20\n\ gripper 0 canL 8\n\ - joint 0 canL shoulder_3 3 180 2.0 9.4 33.0 0.4 250 0.08 0.0 0.6 0.0017 0.2 1.5 60 a4 0.7 0.3 0.1 0.1 0.08\n", + joint 0 canL shoulder_3 3 180 2.0 9.4 33.0 0.4 250 0.08 0.0 0.6 0.0017 0.2 1.5 60 a4 0.7 0.3 0.1 0.1 0.08 40\n", ) .unwrap(); let specs = &cfg.buses[0].2; @@ -1444,37 +1447,45 @@ mod tests { (0.7, 0.3, 0.1, 0.1) ); assert_eq!((specs[0].fl, specs[3].fl), (0.0, 0.08)); + assert_eq!( + (specs[0].stribeck_pole, specs[3].stribeck_pole), + (20.0, 40.0) + ); // An unknown wire token is a bad line, not a silent MIT. assert!(parse_config( - "proto 8\njoint 0 canL shoulder_1 1 250 3.5 9.4 33.0 0.6 250 0.15 0.02 0 0 0 0 60 a9 0 0.3 0.1 0.1 0\n" + "proto 9\njoint 0 canL shoulder_1 1 250 3.5 9.4 33.0 0.6 250 0.15 0.02 0 0 0 0 60 a9 0 0.3 0.1 0.1 0 20\n" ) .is_err()); // A joint line missing the tracker/friction params (the previous // 7-field layout) must be rejected, not defaulted. - assert!(parse_config("proto 8\njoint 0 canL shoulder_1 1 250 3.5\n").is_err()); - // ... and so must the proto-2/3/4/5/6/7 layouts (13 … 23 fields). + assert!(parse_config("proto 9\njoint 0 canL shoulder_1 1 250 3.5\n").is_err()); + // ... and so must the proto-2 … 8 layouts (13 … 24 fields). + assert!(parse_config( + "proto 9\njoint 0 canL shoulder_1 1 250 3.5 9.4 33.0 0.6 250 0.15 0.02\n" + ) + .is_err()); assert!(parse_config( - "proto 8\njoint 0 canL shoulder_1 1 250 3.5 9.4 33.0 0.6 250 0.15 0.02\n" + "proto 9\njoint 0 canL shoulder_1 1 250 3.5 9.4 33.0 0.6 250 0.15 0.02 0 0\n" ) .is_err()); assert!(parse_config( - "proto 8\njoint 0 canL shoulder_1 1 250 3.5 9.4 33.0 0.6 250 0.15 0.02 0 0\n" + "proto 9\njoint 0 canL shoulder_1 1 250 3.5 9.4 33.0 0.6 250 0.15 0.02 0 0 0\n" ) .is_err()); assert!(parse_config( - "proto 8\njoint 0 canL shoulder_1 1 250 3.5 9.4 33.0 0.6 250 0.15 0.02 0 0 0\n" + "proto 9\njoint 0 canL shoulder_1 1 250 3.5 9.4 33.0 0.6 250 0.15 0.02 0 0 0 0 60\n" ) .is_err()); assert!(parse_config( - "proto 8\njoint 0 canL shoulder_1 1 250 3.5 9.4 33.0 0.6 250 0.15 0.02 0 0 0 0 60\n" + "proto 9\njoint 0 canL shoulder_1 1 250 3.5 9.4 33.0 0.6 250 0.15 0.02 0 0 0 0 60 mit\n" ) .is_err()); assert!(parse_config( - "proto 8\njoint 0 canL shoulder_1 1 250 3.5 9.4 33.0 0.6 250 0.15 0.02 0 0 0 0 60 mit\n" + "proto 9\njoint 0 canL shoulder_1 1 250 3.5 9.4 33.0 0.6 250 0.15 0.02 0 0 0 0 60 mit 0 0.3 0.1 0.1\n" ) .is_err()); assert!(parse_config( - "proto 8\njoint 0 canL shoulder_1 1 250 3.5 9.4 33.0 0.6 250 0.15 0.02 0 0 0 0 60 mit 0 0.3 0.1 0.1\n" + "proto 9\njoint 0 canL shoulder_1 1 250 3.5 9.4 33.0 0.6 250 0.15 0.02 0 0 0 0 60 mit 0 0.3 0.1 0.1 0\n" ) .is_err()); } @@ -1485,9 +1496,9 @@ mod tests { #[test] fn parse_config_subset_keeps_joint_slots() { let cfg = parse_config( - "proto 8\n\ - joint 0 can0 wrist_2 6 40 1.0 9.4 33.0 0.0 0.0 0.0 0.0 0 0 0 0 60 mit 0 0.3 0.1 0.1 0\n\ - joint 0 can0 wrist_3 7 40 1.0 9.4 33.0 0.0 0.0 0.0 0.0 0 0 0 0 60 mit 0 0.3 0.1 0.1 0\n\ + "proto 9\n\ + joint 0 can0 wrist_2 6 40 1.0 9.4 33.0 0.0 0.0 0.0 0.0 0 0 0 0 60 mit 0 0.3 0.1 0.1 0 20\n\ + joint 0 can0 wrist_3 7 40 1.0 9.4 33.0 0.0 0.0 0.0 0.0 0 0 0 0 60 mit 0 0.3 0.1 0.1 0 20\n\ gripper 0 can0 8\n", ) .unwrap(); @@ -1499,17 +1510,17 @@ mod tests { // Arm joint ids outside 1..=7 have no slot; a repeated id would // double-book one. assert!(parse_config( - "proto 8\njoint 0 can0 wrist_3 8 40 1.0 9.4 33.0 0 0 0 0 0 0 0 0 60 mit 0 0.3 0.1 0.1 0\n" + "proto 9\njoint 0 can0 wrist_3 8 40 1.0 9.4 33.0 0 0 0 0 0 0 0 0 60 mit 0 0.3 0.1 0.1 0 20\n" ) .is_err()); assert!(parse_config( - "proto 8\njoint 0 can0 bogus 0 40 1.0 9.4 33.0 0 0 0 0 0 0 0 0 60 mit 0 0.3 0.1 0.1 0\n" + "proto 9\njoint 0 can0 bogus 0 40 1.0 9.4 33.0 0 0 0 0 0 0 0 0 60 mit 0 0.3 0.1 0.1 0 20\n" ) .is_err()); assert!(parse_config( - "proto 8\n\ - joint 0 can0 wrist_2 6 40 1.0 9.4 33.0 0 0 0 0 0 0 0 0 60 mit 0 0.3 0.1 0.1 0\n\ - joint 0 can0 wrist_2 6 40 1.0 9.4 33.0 0 0 0 0 0 0 0 0 60 mit 0 0.3 0.1 0.1 0\n" + "proto 9\n\ + joint 0 can0 wrist_2 6 40 1.0 9.4 33.0 0 0 0 0 0 0 0 0 60 mit 0 0.3 0.1 0.1 0 20\n\ + joint 0 can0 wrist_2 6 40 1.0 9.4 33.0 0 0 0 0 0 0 0 0 60 mit 0 0.3 0.1 0.1 0 20\n" ) .is_err()); } @@ -1521,7 +1532,7 @@ mod tests { #[test] fn parse_config_requires_matching_proto() { let joint = - "joint 0 can0 wrist_2 6 40 1.0 9.4 33.0 0 0 0 0 0 0 0 0 60 mit 0 0.3 0.1 0.1 0\n"; + "joint 0 can0 wrist_2 6 40 1.0 9.4 33.0 0 0 0 0 0 0 0 0 60 mit 0 0.3 0.1 0.1 0 20\n"; let error_of = |text: &str| match parse_config(text) { Ok(_) => panic!("accepted a skewed config: {text:?}"), Err(err) => err.to_string(), @@ -1533,12 +1544,12 @@ mod tests { // A future client generation this core does not understand. let err = error_of(&format!("proto 99\n{joint}")); assert!(err.contains("proto 99"), "{err}"); - assert!(err.contains("proto 8"), "{err}"); + assert!(err.contains("proto 9"), "{err}"); // Malformed declarations are bad lines, not silently accepted. assert!(parse_config(&format!("proto\n{joint}")).is_err()); assert!(parse_config(&format!("proto two\n{joint}")).is_err()); // Order does not matter; the line just has to be there. - assert!(parse_config(&format!("{joint}proto 8\n")).is_ok()); + assert!(parse_config(&format!("{joint}proto 9\n")).is_ok()); } } @@ -2058,7 +2069,13 @@ fn bus_loop( v_meas: LpDiff::new(VEL_CUTOFF), bp: BandPass::new(), vel_meas: 0.0, - v_meas_slow: LpDiff::new(CONTROL_CUTOFF), + v_meas_slow: LpDiff::new( + specs + .iter() + .find(|s| s.slot == slot && !s.gripper && s.stribeck_pole > 0.0) + .map(|s| s.stribeck_pole) + .unwrap_or(CONTROL_CUTOFF), + ), vel_meas_slow: 0.0, last_fb: None, dither_phase: slot as f64 * filter::DITHER_PHASE_STAGGER, diff --git a/tests/test_rom_partial_arm.py b/tests/test_rom_partial_arm.py index 0e2c0a2b..330cd3a9 100644 --- a/tests/test_rom_partial_arm.py +++ b/tests/test_rom_partial_arm.py @@ -414,6 +414,7 @@ async def test_bench_arm_streams_soft_pd_and_zero_feedforward(self) -> None: "0.1", "0.1", "0.0", + "20.0", ], line, ) diff --git a/tests/test_rt_link.py b/tests/test_rt_link.py index 397798f7..106b6005 100644 --- a/tests/test_rt_link.py +++ b/tests/test_rt_link.py @@ -100,10 +100,10 @@ def _link(self, proc) -> link.RtLink: def test_config_header_declares_the_protocol(self) -> None: self.assertEqual(link.config_header(), [f"proto {link.CONFIG_PROTO}"]) # 2: slot-by-motor-id; 3/4: stiction fields; 5: dither fields; 6: wire - # mode token; 7: Stribeck fields; 8: load-proportional friction fl on - # every joint line. Bump both sides together - # (rust/axol-rt/src/serve.rs CONFIG_PROTO). - self.assertEqual(link.CONFIG_PROTO, 8) + # mode token; 7: Stribeck fields; 8: load-proportional friction fl; + # 9: the Stribeck velocity pole on every joint line. Bump both sides + # together (rust/axol-rt/src/serve.rs CONFIG_PROTO). + self.assertEqual(link.CONFIG_PROTO, 9) async def test_configure_names_a_stale_binary_when_the_core_exits(self) -> None: rt = self._link(_ExitedProc()) From 962c4890b43b4ae01eaff575c65eda843e701e19 Mon Sep 17 00:00:00 2001 From: Shawn Patel Date: Fri, 18 Sep 2026 19:48:14 -0700 Subject: [PATCH 10/80] axol-rt: drop the unread stribeck_pole copy on ReadyMotor The Stribeck velocity filter takes its pole from the config spec when the per-slot filter state is built, so the per-motor copy was never read (dead_code warning on rt.install). Co-Authored-By: Claude Fable 5.1 --- rust/axol-rt/src/bringup.rs | 3 --- 1 file changed, 3 deletions(-) diff --git a/rust/axol-rt/src/bringup.rs b/rust/axol-rt/src/bringup.rs index 5fb42bd6..21219572 100644 --- a/rust/axol-rt/src/bringup.rs +++ b/rust/axol-rt/src/bringup.rs @@ -134,7 +134,6 @@ pub struct ReadyMotor { pub stribeck_load_gain: f64, pub stribeck_vs: f64, pub fl: f64, - pub stribeck_pole: f64, } /// Status-probe attempts before a silent motor fails the bring-up. @@ -284,7 +283,6 @@ pub fn prepare(sock: &CanSock, iface: &str, specs: &[MotorSpec]) -> io::Result io::Result Date: Fri, 18 Sep 2026 20:38:37 -0700 Subject: [PATCH 11/80] Tuners never torque off a joint that is not at rest; tune.a4 homes on the run's gains MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A 0xA4 elbow probe ended with the forearm rising toward rest and then dropping: the teardown restored the stock firmware gains first, asked the loaded joint to climb home on the stock position loop (which the other branch had already found cannot hold a loaded elbow), swallowed the failed arrival, and then reset + disabled — torque-off with the joint still under gravity. tune.a4 now homes on the run's gains and restores them afterwards (the planner first, when a run left it at 0), and tune.a4, tune.friction and tune.breakaway share _safe_torque_off, which reads every joint and refuses the reset/disable unless all are within 5° of rest, leaving the motors holding and telling the operator what to do. Co-Authored-By: Claude Fable 5.1 --- almond_axol/cli/tune/a4.py | 40 ++++++++++++++------- almond_axol/cli/tune/breakaway.py | 11 +++--- almond_axol/cli/tune/friction.py | 59 +++++++++++++++++++++++++++---- 3 files changed, 84 insertions(+), 26 deletions(-) diff --git a/almond_axol/cli/tune/a4.py b/almond_axol/cli/tune/a4.py index 911edb94..fa26e86c 100644 --- a/almond_axol/cli/tune/a4.py +++ b/almond_axol/cli/tune/a4.py @@ -64,7 +64,7 @@ ) from ...tuning.runner import LiveStream, report_achieved_rate from ..motor import add_side_and_channel_arguments, resolve_channel -from .friction import _home_all, _ramp_verified +from .friction import _home_all, _ramp_verified, _safe_torque_off _MA_POS_CONTROL = 0xA4 _MA_MULTI_TURN_ANGLE = 0x92 @@ -585,7 +585,29 @@ async def _run(args: argparse.Namespace) -> None: except KeyboardInterrupt: print("\n Interrupted.") finally: - print(" Returning to rest and disabling ...") + print(" Returning to rest ...") + # Home *before* restoring the firmware gains: the run's gains are + # the stiffer set, and the stock position loop has been seen to + # stall short of rest on a gravity-loaded elbow. The planner is + # restored first only when the run left it at 0, because a + # direct-tracking joint would otherwise execute the homing target + # at the speed cap. + homed = False + try: + if ( + before_accel is not None + and not args.keep + and accel_used + and accel_used[0] == 0 + ): + got = await _write_accel(driver, before_accel[0], before_accel[1]) + print(f" planner accel/decel restored to {got[0]}/{got[1]} dps/s") + before_accel = None + await _ramp_verified(motors, {joint: 0.0}) + await _home_all(motors) + homed = True + except Exception as exc: # noqa: BLE001 - reported below, arm keeps holding + print(f" ! return to rest did not complete: {exc}") try: if before_gains is not None and not args.keep: await _write_gains(driver, before_gains, args.persist) @@ -595,21 +617,15 @@ async def _run(args: argparse.Namespace) -> None: if before_accel is not None and not args.keep: got = await _write_accel(driver, before_accel[0], before_accel[1]) print(f" planner accel/decel restored to {got[0]}/{got[1]} dps/s") - elif before_accel is not None: + elif before_accel is not None and args.keep: print( f" planner left at {accel_used[0]}/{accel_used[1]} dps/s (--keep) — a direct-tracking joint executes a stored target on wake" ) except Exception as exc: # noqa: BLE001 - report, then keep tearing down print(f" ! restore failed: {exc}") - try: - await _ramp_verified(motors, {joint: 0.0}) - await _home_all(motors) - except Exception: # noqa: BLE001 - best-effort teardown - pass - await asyncio.gather( - *[m.set_control_mode(ControlMode.IMPEDANCE) for m in motors.values()] - ) - await asyncio.gather(*[m.disable() for m in raw.values()]) + if not homed: + print(" (torque-off will be refused unless every joint is at rest)") + await _safe_torque_off(motors, raw) if len(log) < 20: print("\nToo few samples to score.") diff --git a/almond_axol/cli/tune/breakaway.py b/almond_axol/cli/tune/breakaway.py index aadba5ce..64362161 100644 --- a/almond_axol/cli/tune/breakaway.py +++ b/almond_axol/cli/tune/breakaway.py @@ -85,7 +85,7 @@ sweep_safety, ) from ..motor import add_side_and_channel_arguments, resolve_channel -from .friction import _home_all, _ramp_verified +from .friction import _home_all, _ramp_verified, _safe_torque_off _RATE_HZ = 100.0 #: Drift (rad) over one trim hold below which the joint counts as standing @@ -591,12 +591,9 @@ def gravity_fn(q: float) -> float: pass try: await _home_all(motors, exclude=joint if in_impedance else None) - except Exception: # noqa: BLE001 - best-effort teardown - pass - await asyncio.gather( - *[m.set_control_mode(ControlMode.IMPEDANCE) for m in motors.values()] - ) - await asyncio.gather(*[m.disable() for m in motors.values()]) + except Exception as exc: # noqa: BLE001 - reported, arm keeps holding + print(f" ! return to rest did not complete: {exc}") + await _safe_torque_off(motors) _report(results, fc, kp, gains.stiction_gain) diff --git a/almond_axol/cli/tune/friction.py b/almond_axol/cli/tune/friction.py index b5e0977b..8e06f141 100644 --- a/almond_axol/cli/tune/friction.py +++ b/almond_axol/cli/tune/friction.py @@ -43,7 +43,7 @@ from scipy.optimize import curve_fit from ...constants import ARM_JOINTS -from ...motor import CanBus, ControlMode, Joint, Motor +from ...motor import CanBus, ControlMode, Joint, Motor, MotorError from ...robot.axol import arm_limits from ...robot.calibration import CALIBRATION_PATH, update_joint_calibration from ...robot.config import ArmConfig, AxolConfig @@ -162,6 +162,54 @@ async def _ramp_verified( ) +#: A joint this far (rad) from its rest pose still carries gravity load; the +#: tuners refuse to reset/disable it and leave it holding instead. +_REST_TOL = math.radians(5.0) + + +async def _safe_torque_off( + motors: dict[Joint, JointFrameMotor], raw: dict[Joint, Motor] | None = None +) -> bool: + """Reset every joint to IMPEDANCE and disable — but only when every arm + joint is within ``_REST_TOL`` of rest. + + The MyActuator mode switch is a firmware reset (torque drops for ~2 s) + and disable is torque-off; a joint that did not make it home would fall. + That happened on the elbow after a 0xA4 probe whose return climb stalled + on the stock firmware position loop. When a joint is off rest this leaves + every motor holding its last command, says which joint and where, and + returns ``False`` so the caller can tell the operator what to do. + """ + off_rest: list[str] = [] + for j, m in motors.items(): + try: + pos = await m.get_position() + except MotorError: + off_rest.append(f"{j.value} (no position reply)") + continue + if abs(pos) > _REST_TOL: + off_rest.append(f"{j.value} at {math.degrees(pos):+.1f}°") + if off_rest: + print( + " ! NOT disabling: " + + ", ".join(off_rest) + + " — still under gravity load. Motors are left holding their last " + "command. Home the arm (gravity-comp, or hand-guide it to rest) before " + "powering down." + ) + return False + await asyncio.gather( + *[m.set_control_mode(ControlMode.IMPEDANCE) for m in motors.values()] + ) + await asyncio.gather( + *[ + m.disable() + for m in (raw or {j: m.motor for j, m in motors.items()}).values() + ] + ) + return True + + async def _home_all( motors: dict[Joint, JointFrameMotor], exclude: Joint | None = None ) -> None: @@ -854,9 +902,6 @@ async def _run(args: argparse.Namespace) -> None: # including the base-collision joints the old flow used to # leave in place. await _home_all(motors, exclude=joint if in_impedance else None) - except Exception: - pass - await asyncio.gather( - *[m.set_control_mode(ControlMode.IMPEDANCE) for m in motors.values()] - ) - await asyncio.gather(*[m.disable() for m in motors.values()]) + except Exception as exc: # noqa: BLE001 - reported, arm keeps holding + print(f" ! return to rest did not complete: {exc}") + await _safe_torque_off(motors) From fea7a511bb9a6379d619b39291ff6043d2458ca3 Mon Sep 17 00:00:00 2001 From: Shawn Patel Date: Fri, 18 Sep 2026 21:01:24 -0700 Subject: [PATCH 12/80] fw_gains.py --accel: set the position planner alongside the loop gains wire_mode a4 needs both the firmware gains in ROM (the core's bring-up reset reloads them) and planner acceleration 0 on the joint; one command now does both and warns about the stored-target-on-wake behaviour a 0 leaves behind. Co-Authored-By: Claude Fable 5.1 --- scripts/fw_gains.py | 46 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/scripts/fw_gains.py b/scripts/fw_gains.py index a430b449..994d7f5e 100644 --- a/scripts/fw_gains.py +++ b/scripts/fw_gains.py @@ -33,6 +33,27 @@ _READ = 0x30 _WRITE_RAM = 0x31 _WRITE_ROM = 0x32 +_READ_ACCEL = 0x42 +_WRITE_ACCEL = 0x43 # RAM + ROM + + +async def _read_accel(driver: MyActuatorMotor) -> tuple[int, int]: + out = [] + for kind in (0x00, 0x01): + resp = await driver._request(bytes([_READ_ACCEL, kind, 0, 0, 0, 0, 0, 0])) + out.append(int(struct.unpack_from(" tuple[int, int]: + """Position-planner accel and decel, written raw (0 = direct tracking of a + streamed 0xA4 target, which wire_mode a4 needs). Persists in ROM.""" + for kind in (0x00, 0x01): + await driver._request( + bytes([_WRITE_ACCEL, kind, 0, 0]) + struct.pack(" float: @@ -64,9 +85,27 @@ async def _run(args: argparse.Namespace) -> None: if not isinstance(driver, MyActuatorMotor): raise SystemExit(f"motor {args.id:#04x} is not a MyActuator") before = {n: await _read(driver, n) for n in _MA_PID_IDX} + acc = await _read_accel(driver) print(f"motor {args.id:#04x} gains now:") for n, v in before.items(): print(f" {n:12s} {v:.6g}") + print( + f" planner accel/decel {acc[0]}/{acc[1]} dps/s" + + ( + " (direct tracking)" + if acc[0] == 0 + else " (profiled — a streamed 0xA4 will not follow)" + ) + ) + if args.accel is not None and acc != (args.accel, args.accel): + got = await _write_accel(driver, args.accel) + print( + f" planner accel/decel {acc[0]}/{acc[1]} -> {got[0]}/{got[1]} dps/s (ROM)" + ) + if got[0] == 0: + print( + " ! this joint now executes a stored 0xA4 target at its speed cap the moment it wakes — set it back (--accel 5000) when done with wire_mode a4" + ) if not writes: return for n, v in writes.items(): @@ -91,6 +130,13 @@ def main() -> None: add_side_and_channel_arguments(p) p.add_argument("--id", type=lambda x: int(x, 0), required=True, help="Motor CAN ID") p.add_argument("gains", nargs="*", metavar="NAME=VALUE", help="Gains to write") + p.add_argument( + "--accel", + type=int, + default=None, + help="Also set the position-planner accel/decel (dps/s, ROM): 0 for the direct " + "tracking wire_mode a4 needs, 5000 to put a joint back", + ) p.add_argument( "--persist", action="store_true", From 76c5e0fa3e50f56a7e2ba3f864f8d9e4fa3c693b Mon Sep 17 00:00:00 2001 From: Shawn Patel Date: Tue, 22 Sep 2026 00:25:51 +0000 Subject: [PATCH 13/80] Firmware loop gains in the joint config, written to ROM at enable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The X8-P20 shoulders stick-slip at creep speed on their stock 0xA4 loop (position_kp 0.008, ~0.3 Hz): 2.6 Hz velocity cycle, 0.5° stairs, 38 % of a 3 deg/s pass stuck. The tune.a4 sweeps on right shoulder_1 found the knee at position_kp 0.3 with speed_kp 0.1 — stairs gone, velocity ripple 0.26 vs 0.86, zero stuck windows, >20 Hz current still at the stock floor — while 0.5 starts a ~5 Hz loop mode and 1.0 buzzes. speed_ki is *lowered* to 1e-5: the integrator winds up while the joint is stuck and dumps it at release, so the stock 1e-4 feeds the surge (2e-4 limit-cycled at 5 Hz, 6 A). position_kd is carried at stock; 0.1/0.3/0.6 produced identical traces, the firmware stores it but the 0xA4 loop ignores it. JointConfig gains a `firmware` block (FirmwareGains: position_kp/ki/kd, speed_kp/ki, all None = leave the motor alone); shoulder_1 and shoulder_2 default to the set above on both arms. MyActuatorMotor.ensure_rom_gains reads each gain, writes 0x32 only when it differs beyond float32 rounding, settles and reads back, refusing pre-V4.2 firmware. Both enable paths (classic AxolArm bring-up and the realtime core's _enable) apply it to the cold joints while they are still disabled — the only state a MyActuator commits a ROM write in — and the bus is quiet; held joints are never touched and a refusing motor warns instead of failing the enable. Arms built without a config (bench/test) skip it. The gains only act under wire_mode a4; the MIT frame ignores them. Also: calibration-file `firmware` overlay, FirmwareGains export, config docs rows, tune.a4 note, tests/test_firmware_gains.py. Co-Authored-By: Claude Fable 5.1 --- almond_axol/motor/myactuator.py | 58 +++++++++ almond_axol/robot/__init__.py | 2 + almond_axol/robot/axol.py | 66 ++++++++++- almond_axol/robot/config.py | 84 ++++++++++++- almond_axol/rt/robot.py | 16 ++- docs/cli/tune-a4.mdx | 4 + docs/snippets/config/robot.mdx | 5 + tests/test_firmware_gains.py | 204 ++++++++++++++++++++++++++++++++ 8 files changed, 434 insertions(+), 5 deletions(-) create mode 100644 tests/test_firmware_gains.py diff --git a/almond_axol/motor/myactuator.py b/almond_axol/motor/myactuator.py index c5915d2b..c420b0c9 100644 --- a/almond_axol/motor/myactuator.py +++ b/almond_axol/motor/myactuator.py @@ -13,6 +13,7 @@ import math import re import struct +from collections.abc import Mapping from typing import Callable import can @@ -51,6 +52,9 @@ # driver tells the two formats apart at runtime. _MA_READ_GAINS = 0x30 _MA_WRITE_GAINS_ROM = 0x32 # persistent by command; 0x31 (RAM) is not used +# Settle after a 0x32 write before the read-back: the flash commit is not +# instant, and a read that races it returns the old value. +_MA_ROM_SETTLE_S = 0.3 # Indexed float32 parameter indices for 0x30/0x31/0x32 (V4.2+). _MA_PID_IDX = { @@ -62,6 +66,13 @@ "position_ki": 0x08, "position_kd": 0x09, } + + +def _gain_matches(stored: float, wanted: float) -> bool: + """``stored`` is ``wanted`` up to float32 rounding of the wire value.""" + return abs(stored - wanted) <= 1e-6 * max(1.0, abs(wanted)) + + _MA_SET_ACCELERATION = 0x43 # write acceleration to RAM and ROM; persistent by command _MA_READ_ACCELERATION = 0x42 # read one acceleration type; int32 dps/s in bytes 4-7 @@ -634,6 +645,53 @@ async def get_gains(self) -> MotorGains: values[name] = value return MotorGains(**values) + async def ensure_rom_gains( + self, wanted: Mapping[str, float] + ) -> dict[str, tuple[float, float]]: + """Bring the named firmware loop gains to ``wanted`` in ROM (0x32). + + ``wanted`` maps parameter names (keys of ``_MA_PID_IDX``) to values. + Each gain is read first and written only when it differs beyond + float32 rounding, so a provisioned motor costs reads only and the + flash is written once per change. Every write is read back. + + Returns ``{name: (before, after)}`` for the gains that were written. + + The motor must be **disabled**: the firmware commits a 0x32 write to + ROM only in that state (protocol V4.4 §2.3) and silently keeps the + old value otherwise, which the read-back turns into a + :class:`MotorError`. Pre-V4.2 firmware (bulk uint8 gains) is refused + with a :class:`MotorError` rather than written. + """ + unknown = set(wanted) - set(_MA_PID_IDX) + if unknown: + raise ValueError(f"unknown firmware gain(s) {sorted(unknown)}") + changed: dict[str, tuple[float, float]] = {} + for name, value in wanted.items(): + index = _MA_PID_IDX[name] + before = await self._read_gain_indexed(index) + if before is None: + raise MotorError( + f"MyActuator motor {self._motor_id:#04x}: firmware predates the " + f"indexed gain format (protocol V4.2); cannot set {name}" + ) + if _gain_matches(before, value): + continue + await self._request( + bytes([_MA_WRITE_GAINS_ROM, index, 0, 0]) + + struct.pack(" None: # Command 0x32 writes directly to ROM — no separate store step needed. # Probe the read format first so a V4.2+ motor never receives the diff --git a/almond_axol/robot/__init__.py b/almond_axol/robot/__init__.py index 8baee049..21ba4efc 100644 --- a/almond_axol/robot/__init__.py +++ b/almond_axol/robot/__init__.py @@ -16,6 +16,7 @@ from .config import ( ArmConfig, AxolConfig, + FirmwareGains, FrictionParams, JointConfig, PositionForceConfig, @@ -37,6 +38,7 @@ "Jelly", "JellyConfig", "detect_jelly", + "FirmwareGains", "FrictionParams", "JointConfig", "PositionForceConfig", diff --git a/almond_axol/robot/axol.py b/almond_axol/robot/axol.py index 4f29a1bd..619f5297 100644 --- a/almond_axol/robot/axol.py +++ b/almond_axol/robot/axol.py @@ -34,18 +34,19 @@ MotorGains, MotorStatus, ) +from ..motor.myactuator import MyActuatorMotor from ..settings import SHARED from ..utils.paths import almond_path from ..utils.state_files import secure_atomic_write_json, secure_read_text from .base import RobotBase, mark_hardware_cleanup_uncertain from .config import AxolConfig from .control import ( - BandPass, DAMP_BP_Q, DAMP_BP_W0, + VEL_CUTOFF_FREQ, + BandPass, Differentiator, TorqueDither, - VEL_CUTOFF_FREQ, compute_friction, stiction_amplitude, stiction_compensation, @@ -156,6 +157,64 @@ async def _arm_is_unpowered(arm: "AxolArm", bus: CanBus) -> bool: ) +async def apply_firmware_gains(arm: "AxolArm", joints: Iterable[Joint]) -> None: + """Write the configured firmware loop gains of ``joints`` to their motors' ROM. + + For each joint whose :class:`~almond_axol.robot.config.JointConfig` + carries set :class:`~almond_axol.robot.config.FirmwareGains`, the + MyActuator driver compares them with the motor's stored gains and writes + the ones that differ (see ``MyActuatorMotor.ensure_rom_gains``). Meant for + the *cold* joints of a bring-up, called while they are still disabled and + the bus is quiet: a MyActuator commits a ROM write only when disabled, + and joints found holding from a previous session are never touched. + + A joint that will not take the write (pre-V4.2 firmware, no answer, or a + read-back mismatch) keeps its stored gains and is logged as a warning — + it tracks with whatever the motor holds, which is safe, just not the + tuned set — so one joint's firmware cannot fail the whole enable. + """ + arm_config = getattr(arm, "_arm_config", None) + if arm_config is None: + # A bench or test arm built without a config carries no firmware + # gains to apply; there is nothing to compare the motors against. + return + side = "left" if getattr(arm, "_is_left", True) else "right" + for joint in joints: + jc = getattr(arm_config, joint.value, None) + firmware = getattr(jc, "firmware", None) + wanted = firmware.as_dict() if firmware is not None else {} + if not wanted: + continue + driver = getattr(arm.motors.get(joint), "_driver", None) + if not isinstance(driver, MyActuatorMotor): + _logger.warning( + "%s.%s: firmware loop gains configured but the joint is not a " + "MyActuator; ignored", + side, + joint.value, + ) + continue + try: + changed = await driver.ensure_rom_gains(wanted) + except MotorError as exc: + _logger.warning( + "%s.%s: could not apply firmware loop gains %s (%s); the motor " + "keeps its stored gains", + side, + joint.value, + wanted, + exc, + ) + continue + if changed: + _logger.info( + "%s.%s: firmware loop gains written to ROM: %s", + side, + joint.value, + ", ".join(f"{n} {b:g} -> {a:g}" for n, (b, a) in changed.items()), + ) + + async def _rollback_newly_enabled_motors( motors: list[tuple[str, Motor]], setup_error: BaseException ) -> list[tuple[str, Motor, BaseException]]: @@ -1145,6 +1204,9 @@ async def _enable_from_holding_state( self, held: list[Joint], cold: list[Joint], *, hold: bool ) -> None: """Attach held motors and bring cold motors up after state is sampled.""" + # Firmware loop gains go to ROM first, while the cold motors are still + # disabled — the only state a MyActuator commits a 0x32 write in. + await apply_firmware_gains(self, cold) await _await_all_hardware_actions( *[ self.motors[j].attach( diff --git a/almond_axol/robot/config.py b/almond_axol/robot/config.py index 8bfd6018..dab8312b 100644 --- a/almond_axol/robot/config.py +++ b/almond_axol/robot/config.py @@ -29,7 +29,7 @@ import logging import math from collections.abc import Sequence -from dataclasses import dataclass, field, replace +from dataclasses import dataclass, field, fields, replace from typing import Any from ..constants import ARM_JOINTS @@ -78,6 +78,59 @@ class FrictionParams: fl: float = 0.0 +@dataclass +class FirmwareGains: + """MyActuator firmware loop gains, written to the motor's ROM at enable. + + These are the gains of the motor's own cascaded controller — position + PI → speed PI → current PI — which is the loop a joint runs on when its + ``wire_mode`` is ``a4``. Under the MIT impedance frame (``wire_mode`` + ``mit``, the production law) the firmware ignores them; ``kp`` / ``kd`` + on :class:`JointConfig` are the impedance gains and travel with every + command. Each field is ``None`` by default, meaning "leave whatever the + motor holds". A set value is compared against the motor's stored gain + while the joint is still disabled at enable (the only state a MyActuator + accepts a ROM write in) and written only if it differs, so the flash is + touched once per change, not once per bring-up. Identified with + ``axol tune.a4``; protocol V4.2+ firmware only. + + Attributes: + position_kp: Position loop proportional gain: position error → + speed setpoint. Sets the loop bandwidth. Stock 0.008 on the + X8-P20 shoulders (~0.3 Hz), which stick-slips at creep + speed; 0.2 removes the stairs and 0.3 is the knee before + the loop's ~5 Hz mode and a speed-loop buzz appear. + position_ki: Position loop integral gain. Leave at the stock 0: on + top of the speed integrator it hunts around the target. + position_kd: Position loop derivative gain (protocol V4.2+ index + 0x09). Stored and read back by the firmware but measured + inert in the 0xA4 loop on the X8-P20 — 0.1, 0.3 and 0.6 + produced identical traces — so it is carried for + completeness, not as a damping knob. + speed_kp: Speed loop proportional gain: velocity error → current. + The only damping term the 0xA4 loop has; also the buzz + knob (0.15 doubled the >20 Hz current on shoulder_1). + speed_ki: Speed loop integral gain. Lower is smoother on a geared + joint: the integrator winds up while the joint is stuck and + dumps it at release, so the stock 1e-4 feeds the surge + (2e-4 limit-cycled at 5 Hz); 1e-5 halves the mode's current. + """ + + position_kp: float | None = None + position_ki: float | None = None + position_kd: float | None = None + speed_kp: float | None = None + speed_ki: float | None = None + + def as_dict(self) -> dict[str, float]: + """The set gains, keyed by their MyActuator parameter name.""" + return { + f.name: float(v) + for f in fields(self) + if (v := getattr(self, f.name)) is not None + } + + @dataclass class JointConfig: """Full per-joint configuration: gains + friction + driven body inertial. @@ -245,6 +298,10 @@ class JointConfig: the cancellation in the first A/B; 40-80 rad/s follows the surge more closely at the cost of encoder-step noise in the torque (a 16-bit count at 240 Hz is 0.09 rad/s). + firmware: :class:`FirmwareGains` written to the motor's ROM at + enable — the position/speed loop gains behind + ``wire_mode`` ``a4``. All ``None`` (the default) leaves the + motor's stored gains alone; MyActuator joints only. """ kp: float @@ -267,6 +324,7 @@ class JointConfig: stribeck_load_gain: float = 0.1 stribeck_vs: float = 0.1 stribeck_pole: float = 20.0 + firmware: FirmwareGains = field(default_factory=FirmwareGains) @dataclass @@ -288,6 +346,25 @@ class PositionForceConfig: _ZERO_FRICTION = FrictionParams(fc=0.0, k=1.0, fv=0.0, fo=0.0) +# Firmware loop gains for the X8-P20 shoulders (shoulder_1 / shoulder_2), +# from the ``tune.a4`` sweeps on right shoulder_1 (2026-09-18/21, 3 and +# 12 deg/s triangles at -45°): position_kp 0.3 is the knee where the creep +# stairs are gone (velocity ripple 0.26 vs 0.86 stock, zero stuck windows) +# and the >20 Hz current is still at the stock floor; 0.5 starts a ~5 Hz +# loop mode, 1.0 buzzes. speed_kp 0.1 (3x stock) is the damping — 0.15 +# doubled the buzz. speed_ki is *lowered* from the stock 1e-4: the +# integrator winds up while the joint is stuck and dumps it at release. +# position_kd is the stock value; the firmware stores it but the 0xA4 loop +# measured inert to it. Written to ROM once at enable (see +# :class:`FirmwareGains`); they only act under ``wire_mode`` ``a4``. +_X8_FIRMWARE_GAINS = FirmwareGains( + position_kp=0.3, + position_kd=0.1, + speed_kp=0.1, + speed_ki=1e-5, +) + + @dataclass class ArmConfig: """Per-joint configuration for a single arm. @@ -333,6 +410,7 @@ class ArmConfig: # Pose-tracked band-pass centre (kd_host_hz None): the shoulder # mode is the impedance mode, moving with reflected inertia. kd_host=40.0, + firmware=_X8_FIRMWARE_GAINS, ) ) shoulder_2: JointConfig = field( @@ -344,6 +422,7 @@ class ArmConfig: com=(0.0, 0.0115864, -0.0302711), j_eff=1.1, kd_host=35.0, + firmware=_X8_FIRMWARE_GAINS, ) ) shoulder_3: JointConfig = field( @@ -527,6 +606,9 @@ def _calibrated_joint(jc: JointConfig, entry: dict[str, Any]) -> JointConfig: friction = entry.get("friction") if friction is not None: overrides["friction"] = FrictionParams(**friction) + firmware = entry.get("firmware") + if firmware is not None: + overrides["firmware"] = FirmwareGains(**firmware) com = entry.get("com") if com is not None: # Fitted by ``axol tune.gravity --save``; already per-side (measured diff --git a/almond_axol/rt/robot.py b/almond_axol/rt/robot.py index db0fb828..40126464 100644 --- a/almond_axol/rt/robot.py +++ b/almond_axol/rt/robot.py @@ -79,7 +79,12 @@ from ..motor import ControlMode, Joint, Motor, MotorError, MotorGains, MotorStatus from ..motor.bus import CanBus from ..motor.motor import _JOINT_CONFIG -from ..robot.axol import AxolArm, AxolHardware, _rollback_newly_enabled_motors +from ..robot.axol import ( + AxolArm, + AxolHardware, + _rollback_newly_enabled_motors, + apply_firmware_gains, +) from ..robot.base import RobotBase, mark_hardware_cleanup_uncertain from ..robot.config import AxolConfig from ..settings import SHARED @@ -472,16 +477,23 @@ async def _enable(self) -> None: # is still holding, so this is exactly the classic held/cold split. # Only the cold set is rolled back if the bring-up fails from here. cold: list[tuple[str, Motor]] = [] + cold_joints: dict[int, list[Joint]] = {} for side, arm in self._arms(): label = "left" if side == 0 else "right" flags = await arm.get_holding() for joint, holding in zip(arm.motors, flags): if not holding: cold.append((f"{label}.{joint.value}", arm.motors[joint])) + cold_joints.setdefault(side, []).append(joint) self._enable_cold = cold - for _side, arm in self._arms(): + for side, arm in self._arms(): await arm.resolve_joint_offsets() + # The configured firmware loop gains (wire_mode a4's controller) + # go to ROM now: the cold joints have just been reset by prep and + # are disabled, which is the only state a MyActuator commits a + # ROM write in, and the bus is quiet. Held joints are skipped. + await apply_firmware_gains(arm, cold_joints.get(side, [])) # Python never calls Motor.enable() in production control, so run the # MyActuator capability detection (position/torque decode ranges) # and undervoltage provisioning explicitly. Otherwise passive diff --git a/docs/cli/tune-a4.mdx b/docs/cli/tune-a4.mdx index 4178a5e3..428e12d0 100644 --- a/docs/cli/tune-a4.mdx +++ b/docs/cli/tune-a4.mdx @@ -43,6 +43,10 @@ axol tune.a4 --r --joint shoulder_1 --accel 0 --speed-kp 0.05 --speed-ki 0.0005 axol tune.a4 --r --joint shoulder_1 --mode sine --freq 0.3 --accel 0 --position-kp 0.02 --save-run ``` + + A winning set does not have to be persisted from here. The robot config carries per-joint `firmware.*` gains (see the [config reference](/cli/configuration)); `enable()` compares them with the motor's ROM while the joint is still disabled and writes only what differs. The X8-P20 shoulders ship configured with `position_kp 0.3`, `position_kd 0.1`, `speed_kp 0.1`, `speed_ki 1e-5` from these sweeps. + + A joint left with planner acceleration 0 executes any stored position target at the speed cap the moment it wakes. `tune.a4` restores the stored acceleration unless you pass `--keep`; if you keep it, do not drive that joint with any other position command until you have set it back. diff --git a/docs/snippets/config/robot.mdx b/docs/snippets/config/robot.mdx index 8665328e..851e5fcb 100644 --- a/docs/snippets/config/robot.mdx +++ b/docs/snippets/config/robot.mdx @@ -32,6 +32,11 @@ export const F = ({ children }) => {child | --{prefix}left.elbow.friction.fv | per motor | Viscous friction (Nm·s/rad). | | --{prefix}left.elbow.friction.fo | per motor | Constant torque offset (Nm). | | --{prefix}left.elbow.friction.fl | `0.0` | Load-proportional Coulomb friction, Nm per Nm of gravity feed-forward: the effective `fc` is `fc + fl × |gravity|`. Planetary gear friction grows with the torque it carries (right shoulder_1: ~0.6 Nm sliding at rest, ~1.5 Nm under 12 Nm), so a constant `fc` over-compensates at rest and under-compensates at reach. `0` keeps the constant model. | +| --{prefix}left.elbow.firmware.position_kp | `null` (shoulders `0.3`) | **MyActuator firmware** position-loop proportional gain, written to the motor's ROM at enable when set (`null` leaves the motor's stored value). These five gains are the motor's own position PI → speed PI controller and only act under `wire_mode` `a4`; identify them with [`tune.a4`](/cli/tune-a4). Stock 0.008 on the X8-P20 shoulders stick-slips at creep speed; 0.3 is the knee where the stairs are gone and the loop has not started to buzz. | +| --{prefix}left.elbow.firmware.position_ki | `null` | Firmware position-loop integral gain. Leave at the stock `0`: on top of the speed integrator it hunts around the target. | +| --{prefix}left.elbow.firmware.position_kd | `null` (shoulders `0.1`) | Firmware position-loop derivative gain (protocol V4.2+). The motor stores it, but the 0xA4 loop on the X8-P20 measured inert to it. | +| --{prefix}left.elbow.firmware.speed_kp | `null` (shoulders `0.1`) | Firmware speed-loop proportional gain: the 0xA4 loop's only damping term, and its buzz knob (0.15 doubled the >20 Hz current on shoulder_1). | +| --{prefix}left.elbow.firmware.speed_ki | `null` (shoulders `1e-05`) | Firmware speed-loop integral gain. Lower than the stock `1e-4` is smoother on a geared joint: the integrator winds up while the joint is stuck and dumps it at release. | Per-arm and per-joint flags are shown for `left` / `elbow`. Swap in `right`, or any joint: `shoulder_1`, `shoulder_2`, `shoulder_3`, `elbow`, `wrist_1`, `wrist_2`, `wrist_3`. diff --git a/tests/test_firmware_gains.py b/tests/test_firmware_gains.py new file mode 100644 index 00000000..17c41e00 --- /dev/null +++ b/tests/test_firmware_gains.py @@ -0,0 +1,204 @@ +"""Firmware loop gains: config carriage, the idempotent ROM write on the +MyActuator driver, and the enable-time hook that applies them to cold joints.""" + +from __future__ import annotations + +import struct +import unittest +from dataclasses import replace +from types import SimpleNamespace +from unittest.mock import MagicMock, patch + +from almond_axol.constants import Joint +from almond_axol.motor import MotorError +from almond_axol.motor.myactuator import _MA_PID_IDX, MyActuatorMotor +from almond_axol.robot import FirmwareGains, JointConfig +from almond_axol.robot.axol import apply_firmware_gains +from almond_axol.robot.config import AxolConfig, _calibrated_joint + +_X8 = {"position_kp": 0.3, "position_kd": 0.1, "speed_kp": 0.1, "speed_ki": 1e-5} + + +class ConfigTest(unittest.TestCase): + def test_shoulders_carry_the_x8_firmware_gains_on_both_arms(self) -> None: + cfg = AxolConfig() + for arm in (cfg.left, cfg.right): + for joint in (arm.shoulder_1, arm.shoulder_2): + self.assertEqual(joint.firmware.as_dict(), _X8) + self.assertIsNone(joint.firmware.position_ki) + + def test_other_joints_leave_the_motor_alone(self) -> None: + arm = AxolConfig().left + for name in ("shoulder_3", "elbow", "wrist_1", "wrist_2", "wrist_3"): + self.assertEqual(getattr(arm, name).firmware.as_dict(), {}) + + def test_defaults_survive_the_stiffness_blend(self) -> None: + cfg = AxolConfig(left_stiffness=0.3).resolved() + self.assertEqual(cfg.left.shoulder_1.firmware.as_dict(), _X8) + + def test_calibration_entry_overlays_firmware_block(self) -> None: + base = AxolConfig().left.elbow + out = _calibrated_joint(base, {"firmware": {"position_kp": 0.05}}) + self.assertEqual(out.firmware.as_dict(), {"position_kp": 0.05}) + # Untouched entries keep the config's block. + self.assertIs(_calibrated_joint(base, {"kp": 100.0}).firmware, base.firmware) + + def test_firmware_gains_is_exported_and_replaceable(self) -> None: + jc = replace(AxolConfig().left.elbow, firmware=FirmwareGains(speed_kp=0.05)) + self.assertIsInstance(jc, JointConfig) + self.assertEqual(jc.firmware.as_dict(), {"speed_kp": 0.05}) + + +class _FakeMotor(MyActuatorMotor): + """A V4.2+ motor's gain store behind ``_request``; ROM writes take only + while ``enabled`` is False, as on hardware.""" + + def __init__(self, store: dict[int, float], *, enabled: bool = False) -> None: + super().__init__(MagicMock(), 0x01, kt=2.0) + self.store = store + self.enabled = enabled + self.writes: list[tuple[int, float]] = [] + + async def _request(self, data: bytes, *args, **kwargs) -> bytes: # type: ignore[override] + cmd, index = data[0], data[1] + if cmd == 0x30: + return bytes([0x30, index, 0, 0]) + struct.pack(" bytes: # type: ignore[override] + # Pre-V4.2 bulk reply: byte 1 is zero, six uint8 gains follow. + return bytes([0x30, 0, 50, 50, 100, 5, 100, 5]) + + +def _stock() -> dict[int, float]: + return { + _MA_PID_IDX["current_kp"]: 0.8, + _MA_PID_IDX["current_ki"]: 0.08, + _MA_PID_IDX["speed_kp"]: 0.03, + _MA_PID_IDX["speed_ki"]: 1e-4, + _MA_PID_IDX["position_kp"]: 0.008, + _MA_PID_IDX["position_ki"]: 0.0, + _MA_PID_IDX["position_kd"]: 0.1, + } + + +class EnsureRomGainsTest(unittest.IsolatedAsyncioTestCase): + async def asyncSetUp(self) -> None: + # No flash settle in tests. + patcher = patch("almond_axol.motor.myactuator._MA_ROM_SETTLE_S", 0.0) + patcher.start() + self.addCleanup(patcher.stop) + + async def test_writes_only_the_gains_that_differ(self) -> None: + motor = _FakeMotor(_stock()) + changed = await motor.ensure_rom_gains(_X8) + # position_kd is already 0.1 in the motor: read, not written. + self.assertEqual(set(changed), {"position_kp", "speed_kp", "speed_ki"}) + self.assertEqual( + [i for i, _ in motor.writes], + [_MA_PID_IDX[n] for n in ("position_kp", "speed_kp", "speed_ki")], + ) + self.assertAlmostEqual(changed["position_kp"][0], 0.008) + self.assertAlmostEqual(changed["position_kp"][1], 0.3, places=6) + self.assertAlmostEqual(motor.store[_MA_PID_IDX["speed_ki"]], 1e-5, places=9) + + async def test_second_call_is_read_only(self) -> None: + motor = _FakeMotor(_stock()) + await motor.ensure_rom_gains(_X8) + motor.writes.clear() + self.assertEqual(await motor.ensure_rom_gains(_X8), {}) + self.assertEqual(motor.writes, []) + + async def test_float32_rounding_counts_as_a_match(self) -> None: + store = _stock() + store[_MA_PID_IDX["speed_ki"]] = struct.unpack(" None: + motor = _FakeMotor(_stock(), enabled=True) + with self.assertRaisesRegex(MotorError, "reads back"): + await motor.ensure_rom_gains({"position_kp": 0.3}) + + async def test_legacy_firmware_is_refused_not_written(self) -> None: + motor = _LegacyMotor(_stock()) + with self.assertRaisesRegex(MotorError, "V4.2"): + await motor.ensure_rom_gains({"position_kp": 0.3}) + self.assertEqual(motor.writes, []) + + async def test_unknown_gain_name_is_a_programming_error(self) -> None: + with self.assertRaises(ValueError): + await _FakeMotor(_stock()).ensure_rom_gains({"current_kd": 1.0}) + + +def _arm(drivers: dict[Joint, object], *, is_left: bool = True) -> SimpleNamespace: + cfg = AxolConfig() + return SimpleNamespace( + _is_left=is_left, + _arm_config=cfg.left if is_left else cfg.right, + motors={j: SimpleNamespace(_driver=d) for j, d in drivers.items()}, + ) + + +class ApplyFirmwareGainsTest(unittest.IsolatedAsyncioTestCase): + async def asyncSetUp(self) -> None: + patcher = patch("almond_axol.motor.myactuator._MA_ROM_SETTLE_S", 0.0) + patcher.start() + self.addCleanup(patcher.stop) + + async def test_cold_shoulders_get_the_config_gains_and_others_are_untouched( + self, + ) -> None: + s1, s2, elbow = _FakeMotor(_stock()), _FakeMotor(_stock()), _FakeMotor(_stock()) + arm = _arm({Joint.SHOULDER_1: s1, Joint.SHOULDER_2: s2, Joint.ELBOW: elbow}) + with self.assertLogs("almond_axol.robot.axol", level="INFO") as logs: + await apply_firmware_gains( + arm, [Joint.SHOULDER_1, Joint.SHOULDER_2, Joint.ELBOW] + ) + for motor in (s1, s2): + self.assertAlmostEqual(motor.store[_MA_PID_IDX["position_kp"]], 0.3, 6) + self.assertAlmostEqual(motor.store[_MA_PID_IDX["speed_kp"]], 0.1, 6) + self.assertAlmostEqual(motor.store[_MA_PID_IDX["speed_ki"]], 1e-5, 9) + self.assertEqual(elbow.writes, []) + self.assertEqual(sum("written to ROM" in m for m in logs.output), 2) + + async def test_held_joints_are_not_in_the_list_so_nothing_is_written(self) -> None: + s1 = _FakeMotor(_stock()) + await apply_firmware_gains(_arm({Joint.SHOULDER_1: s1}), []) + self.assertEqual(s1.writes, []) + + async def test_a_refusing_motor_warns_and_does_not_fail_enable(self) -> None: + s1 = _FakeMotor(_stock(), enabled=True) + arm = _arm({Joint.SHOULDER_1: s1}, is_left=False) + with self.assertLogs("almond_axol.robot.axol", level="WARNING") as logs: + await apply_firmware_gains(arm, [Joint.SHOULDER_1]) + self.assertTrue(any("right.shoulder_1" in m for m in logs.output)) + + async def test_gripper_and_non_myactuator_joints_are_skipped(self) -> None: + arm = _arm({Joint.GRIPPER: object(), Joint.WRIST_2: object()}) + # Gripper config has no firmware block; wrist_2 has an empty one. + await apply_firmware_gains(arm, [Joint.GRIPPER, Joint.WRIST_2]) + + async def test_configured_gains_on_a_damiao_joint_warn(self) -> None: + arm = _arm({Joint.WRIST_2: object()}) + arm._arm_config = replace( + arm._arm_config, + wrist_2=replace( + arm._arm_config.wrist_2, firmware=FirmwareGains(speed_kp=1) + ), + ) + with self.assertLogs("almond_axol.robot.axol", level="WARNING") as logs: + await apply_firmware_gains(arm, [Joint.WRIST_2]) + self.assertTrue(any("not a MyActuator" in m for m in logs.output)) + + +if __name__ == "__main__": + unittest.main() From 62b170894271531b103d11bc5a10937351b5ab64 Mon Sep 17 00:00:00 2001 From: Shawn Patel Date: Tue, 22 Sep 2026 00:33:08 +0000 Subject: [PATCH 14/80] Dashboard: per-joint controller picker on the Recorded-motion tab MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit tune.motion has taken `--a4 SIDE.JOINT` (repeatable) since 6622d12: the named MyActuator joint runs the replay on the firmware's 0xA4 position loop while every other joint stays on the MIT impedance frame, so "shoulder_1 on the firmware loop, everything else on impedance" can be A/B'd against the identical motion. The CLI was the only way to reach it — the workbench's Recorded-motion tab had no control for it. The tab now carries a controller-per-joint grid (rows: the five MyActuator joints; columns: left/right; each cell toggles impedance ↔ firmware). It serializes to the `side.joint` token string the CLI takes, which the server already fans out into one `--a4` per token, so the launch path is unchanged. Cells the robot's config pins to `wire_mode a4` show as firmware and cannot be switched back (a run can add `--a4` joints, not remove them); for that the gains endpoint now also reports each joint's configured `wire_modes`. tune.motion records its `--a4` list in the run params, so clicking a saved run re-arms the same controller split. Helpers live in web/app/src/lib/wire-mode.ts with node tests; the dashboard guide describes the grid. Co-Authored-By: Claude Fable 5.1 --- almond_axol/cli/tune/motion.py | 3 + almond_axol/serve/app.py | 13 +- docs/guides/diagnostics-dashboard.mdx | 2 +- .../diagnostics/tuning-workbench.tsx | 137 +++++++++++++++++- web/app/src/lib/tuning.ts | 12 +- web/app/src/lib/wire-mode.ts | 73 ++++++++++ web/app/test/wire-mode.test.mjs | 36 +++++ 7 files changed, 269 insertions(+), 7 deletions(-) create mode 100644 web/app/src/lib/wire-mode.ts create mode 100644 web/app/test/wire-mode.test.mjs diff --git a/almond_axol/cli/tune/motion.py b/almond_axol/cli/tune/motion.py index 0004a523..75125520 100644 --- a/almond_axol/cli/tune/motion.py +++ b/almond_axol/cli/tune/motion.py @@ -700,6 +700,9 @@ async def execute( "rate": motion.rate, "stiffness": args.stiffness, "columns": _COLUMNS, + # Joints driven on the firmware position loop (--a4) for this + # run, so the dashboard can re-arm the same controller split. + "a4": list(args.a4), **stream_info, }, label=args.label, diff --git a/almond_axol/serve/app.py b/almond_axol/serve/app.py index fe8357e6..2f09f2b5 100644 --- a/almond_axol/serve/app.py +++ b/almond_axol/serve/app.py @@ -1936,7 +1936,10 @@ async def tuning_gains() -> dict[str, Any]: file overlaid — exactly what a tuning run uses when a gain field is left empty. The workbench shows these as the slider baselines. ``kd_host_hz`` is resolved to the shared default where a joint - doesn't set its own band centre. + doesn't set its own band centre. ``wire_modes`` carries each joint's + configured controller (``mit`` impedance or ``a4`` firmware position + loop): the Recorded-motion tab's per-joint controller picker seeds + from it, since a run adds ``--a4`` joints on top of the config. """ import math @@ -1947,11 +1950,14 @@ async def tuning_gains() -> dict[str, Any]: def _load() -> dict[str, Any]: cfg = AxolConfig() out: dict[str, Any] = {} + wire: dict[str, Any] = {} for side in ("left", "right"): arm_cfg = getattr(cfg, side) joints: dict[str, Any] = {} + modes: dict[str, str] = {} for j in ARM_JOINTS: jc = getattr(arm_cfg, j.value) + modes[j.value] = str(jc.wire_mode).lower() joints[j.value] = { "kp": jc.kp, "kd": jc.kd, @@ -1971,9 +1977,10 @@ def _load() -> dict[str, Any]: "stribeck_gain": jc.stribeck_gain, } out[side] = joints - return out + wire[side] = modes + return {"gains": out, "wire_modes": wire} - return {"gains": await asyncio.to_thread(_load)} + return await asyncio.to_thread(_load) @app.get("/api/tuning/runs") async def tuning_runs() -> dict[str, Any]: diff --git a/docs/guides/diagnostics-dashboard.mdx b/docs/guides/diagnostics-dashboard.mdx index 3e10da0a..9f79abd8 100644 --- a/docs/guides/diagnostics-dashboard.mdx +++ b/docs/guides/diagnostics-dashboard.mdx @@ -61,7 +61,7 @@ Gain fields (kp, kd, kd_host, kd_host_hz, kd_host_q) show the selected joint's * - **Factory** — [`tune.factory`](https://docs.almond.bot/cli/tune-factory) runs friction + gravity for **all 14 joints** in one go (both arms, distal→proximal), saving every fit to this robot's calibration and uploading the document to the cloud keyed by the hub adapter serial when Supabase credentials are configured. This is the one-button factory calibration; expect a long run. To compare two runs, tick their checkboxes in the run list (same run kind — sine vs sine, motion vs motion). The view switches to an overlay: commanded plus both runs' outputs per joint (A yellow, B blue), both error traces in the lane, a verdict line naming the better run by its headline score, and an A-vs-B score table with the better value of each pair in green. Untick (or Exit compare) to go back to single-run view. -- **Recorded motion** — [`tune.motion`](https://docs.almond.bot/cli/tune-motion) replays a committed reference motion through the production Rust-core control path and scores tracking per joint. The gain table shows every joint's current config value (defaults + this robot's calibration) for each gain field; edit a cell to override it **for this run only, on both arms** — edited cells highlight, everything else runs with config. Clearing a cell drops the override and snaps it back to the config value (**reset overrides** clears them all). Tick **run as IK** to drive the run through the IK solver instead of raw joint replay: the motion's Cartesian end-effector path (FK of the reference, with elbow hints) is re-solved to joints like teleop's pose→joints loop and the arms execute the solver's output — still scored against the clean reference, so IK reconstruction error and tracking show up together in the same charts. The tab also carries the filter-stack test on real hardware: pick a **noise** source (**network** = seeded jitter/outliers/stalls injected before the pose low-pass, **IK** = solver churn/jumps injected after it, or **combined** — each at its real entry point in the pipeline, deterministic per seed) and toggle the **filter stack** (pose low-pass → EMA → trapezoid). The run is scored against the clean reference either way, and the charts overlay **commanded (clean), sent (what was actually streamed), and actual** per joint — run once with the filters off and once on to see, on the physical arm, what the stack removes and what it costs in lag. +- **Recorded motion** — [`tune.motion`](https://docs.almond.bot/cli/tune-motion) replays a committed reference motion through the production Rust-core control path and scores tracking per joint. The gain table shows every joint's current config value (defaults + this robot's calibration) for each gain field; edit a cell to override it **for this run only, on both arms** — edited cells highlight, everything else runs with config. Clearing a cell drops the override and snaps it back to the config value (**reset overrides** clears them all). The **controller per joint** grid below it picks, per arm and MyActuator joint, whether that joint runs on the production **impedance** frame or on the motor's own **firmware** position loop (`--a4 side.joint`, the gains on the Firmware-loop tab) for this run — e.g. shoulder_1 on the firmware loop with everything else on impedance, scored against the identical motion; a joint the config already pins to `wire_mode a4` shows as firmware and cannot be switched back. Tick **run as IK** to drive the run through the IK solver instead of raw joint replay: the motion's Cartesian end-effector path (FK of the reference, with elbow hints) is re-solved to joints like teleop's pose→joints loop and the arms execute the solver's output — still scored against the clean reference, so IK reconstruction error and tracking show up together in the same charts. The tab also carries the filter-stack test on real hardware: pick a **noise** source (**network** = seeded jitter/outliers/stalls injected before the pose low-pass, **IK** = solver churn/jumps injected after it, or **combined** — each at its real entry point in the pipeline, deterministic per seed) and toggle the **filter stack** (pose low-pass → EMA → trapezoid). The run is scored against the clean reference either way, and the charts overlay **commanded (clean), sent (what was actually streamed), and actual** per joint — run once with the filters off and once on to see, on the physical arm, what the stack removes and what it costs in lag. - **Build motion** — [`motion.build`](https://docs.almond.bot/cli/motion-build) turns a recorded session — teleoperated (`axol teleop --teleop.record`) or hand-guided in gravity comp (`axol gravity-comp --record`, also settable per run on the gravity-comp operation panel) — into a reference motion, which then shows up in the Recorded motion picker. Every build saves a **before/after run**: the raw recorded command stream vs the built motion per joint (with a built−recorded lane), so the smoothing pass and the collision projection are visually checkable — with the same zoom/fullscreen as every other chart — before the motion is replayed. - **IK** — [`diag.offline kinematics`](https://docs.almond.bot/cli/diag-offline) analyzes a teleop recording offline (no hardware, defaults to the newest recording): per-axis charts of the **EE pose set vs actual** (the world target given to the solver vs FK of what it solved) with an error lane in mm, the per-tick **solve time** trace (spikes here surface as teleop lurches), and a per-joint churn/jitter scorecard that catches a restless null space. diff --git a/web/app/src/components/diagnostics/tuning-workbench.tsx b/web/app/src/components/diagnostics/tuning-workbench.tsx index e273ee2f..ca86092a 100644 --- a/web/app/src/components/diagnostics/tuning-workbench.tsx +++ b/web/app/src/components/diagnostics/tuning-workbench.tsx @@ -20,8 +20,16 @@ import { type TuningRecording, type TuningRunData, type TuningRunMeta, + type TuningWireModes, } from "@/lib/tuning" import { fetchMotorDetails } from "@/lib/telemetry" +import { + MYACTUATOR_JOINTS, + SIDES, + effectiveWireMode, + parseA4Tokens, + toggleA4Token, +} from "@/lib/wire-mode" const COMMANDED_COLOR = "rgba(255,255,255,0.45)" const ACTUAL_COLOR = "#eff483" @@ -56,7 +64,7 @@ const KNOWN_KINDS = new Set(["sine", "step", "motion", "gravity", "filter", "bui interface WbField { key: string label: string - type: "number" | "text" | "select" | "boolean" | "overrides" | "pose" + type: "number" | "text" | "select" | "boolean" | "overrides" | "pose" | "wire" options?: string[] /** Placeholder shown when empty; empty means "command default". */ placeholder?: string @@ -408,6 +416,20 @@ const TABS: WbTab[] = [ { key: "motion", label: "motion", type: "select", options: [] }, { key: "stiffness", label: "stiffness s", type: "number", placeholder: "1" }, { key: "gain", label: "gains — edit a cell to override it for this run", type: "overrides" }, + { + key: "a4", + label: "controller per joint — click a cell to put that joint on the firmware loop", + type: "wire", + hint: + "impedance is the production MIT frame with the host's gravity, " + + "friction and damping feed-forward; firmware hands the joint to the " + + "motor's own 0xA4 position loop (the gains on the Firmware-loop tab, " + + "written to ROM at enable) for this run only — no compliance, no host " + + "feed-forward and NaN torque telemetry on that joint. Everything else " + + "about the replay is unchanged, so runs compare directly. Only " + + "MyActuator joints have a firmware loop; the Damiao wrists stay on " + + "impedance. A joint already configured wire_mode a4 is pinned.", + }, { key: "ik", label: "run as IK", @@ -686,6 +708,103 @@ function fmtGain(v: unknown): string { return String(Number(v.toFixed(3))) } +/** + * Per-joint controller picker for tune.motion: one row per MyActuator joint, + * one cell per arm, each a two-way toggle between the MIT impedance frame + * and the firmware position loop (`--a4 side.joint`). Cells the robot's + * config already pins to `wire_mode a4` show as firmware and cannot be + * switched back — a run can only add `--a4` joints. Serializes to the token + * string the CLI takes, so the launch path and run re-arming stay generic. + */ +function WireModeEditor({ + value, + onChange, + disabled, + configModes, +}: { + value: string + onChange: (v: string) => void + disabled: boolean + configModes: TuningWireModes | null +}) { + const picked = parseA4Tokens(value) + return ( +

+ + + + + ))} + + + + {MYACTUATOR_JOINTS.map((joint) => ( + + + {SIDES.map((side) => { + const mode = effectiveWireMode(value, configModes, side, joint) + const pinned = + (configModes?.[side]?.[joint] ?? "mit").toLowerCase() === "a4" && + !picked.has(`${side}.${joint}`) + const dirty = picked.has(`${side}.${joint}`) + return ( + + ) + })} + + ))} + +
+ {SIDES.map((side) => ( + + {side} +
{joint} + +
+ {picked.size > 0 && ( +
+ + {picked.size} joint{picked.size === 1 ? "" : "s"} on the firmware loop this run + + +
+ )} +
+ ) +} + /** * Full gain table for tune.motion's per-run overrides: one row per joint, * one column per gain field, every cell pre-filled with this robot's @@ -1013,6 +1132,9 @@ function runFormValues(meta: TuningRunMeta): Record | null { .map(([k, v]) => `${k}=${v}`) .join(" ") if (overrides) out["gain"] = overrides + if (Array.isArray(p.a4) && p.a4.length > 0) { + out["a4"] = p.a4.filter((t): t is string => typeof t === "string").join(" ") + } break } case "gravity": @@ -2020,6 +2142,7 @@ export function TuningWorkbench({ // Effective per-joint config gains (defaults + calibration): the slider // baselines and "config N" labels on the gain fields. const [gains, setGains] = useState(null) + const [wireModes, setWireModes] = useState(null) // The selected motor's live firmware loop gains (Firmware-loop tab): read // from the motor over the idle link whenever arm/joint change or a run // ends, so the baselines are what the motor actually holds right now. @@ -2069,7 +2192,10 @@ export function TuningWorkbench({ const refreshGains = useCallback(() => { fetchTuningGains() - .then(({ gains }) => setGains(gains)) + .then(({ gains, wire_modes }) => { + setGains(gains) + setWireModes(wire_modes ?? null) + }) .catch(() => {}) }, []) @@ -2455,6 +2581,13 @@ export function TuningWorkbench({ disabled={runningOurs || busy} gains={gains} /> + ) : f.type === "wire" ? ( + setValue(f.key, v)} + disabled={runningOurs || busy} + configModes={wireModes} + /> ) : f.type === "pose" ? ( >> -export async function fetchTuningGains(): Promise<{ gains: TuningGains }> { +/** + * `side → joint → "mit" | "a4"`: the controller each joint is configured to + * run on (impedance frame, or the firmware position loop behind `wire_mode + * a4`). A tune.motion run can put more joints on `a4` with `--a4`. + */ +export type TuningWireModes = Record> + +export async function fetchTuningGains(): Promise<{ + gains: TuningGains + wire_modes?: TuningWireModes +}> { return json(await fetch(apiUrl("/api/tuning/gains"))) } diff --git a/web/app/src/lib/wire-mode.ts b/web/app/src/lib/wire-mode.ts new file mode 100644 index 00000000..37d27fb4 --- /dev/null +++ b/web/app/src/lib/wire-mode.ts @@ -0,0 +1,73 @@ +/** + * Per-joint controller split for `tune.motion --a4`. + * + * A reference-motion replay drives every joint on the MIT impedance frame + * (the production law) unless the joint is named with `--a4 SIDE.JOINT`, + * which hands it to the motor's own firmware position loop (0xA4) for that + * run — the way to A/B "shoulder_1 on the firmware loop, everything else on + * impedance" against the identical motion. The flag is repeatable and the + * server turns a whitespace-separated field into one `--a4` per token, so + * the form value is the token string the CLI takes: `right.shoulder_1 + * left.shoulder_1`. Only MyActuator joints have a firmware loop to hand + * over to; the Damiao wrists always run MIT. + */ + +/** The MyActuator joints, in arm order — the only ones `--a4` accepts usefully. */ +export const MYACTUATOR_JOINTS = ["shoulder_1", "shoulder_2", "shoulder_3", "elbow", "wrist_1"] + +export const SIDES = ["left", "right"] as const +export type Side = (typeof SIDES)[number] + +/** `side.joint` token for one cell of the picker. */ +export function a4Token(side: string, joint: string): string { + return `${side}.${joint}` +} + +/** The `side.joint` tokens set in a form value (unknown tokens are dropped). */ +export function parseA4Tokens(value: string | undefined): Set { + const out = new Set() + for (const tok of (value ?? "").split(/\s+/).filter(Boolean)) { + const [side = "", joint = ""] = tok.split(".") + if ((SIDES as readonly string[]).includes(side) && MYACTUATOR_JOINTS.includes(joint)) { + out.add(a4Token(side, joint)) + } + } + return out +} + +/** Serialize back to the token string, in a stable side-major order. */ +export function serializeA4Tokens(tokens: Iterable): string { + const have = new Set(tokens) + const out: string[] = [] + for (const side of SIDES) { + for (const joint of MYACTUATOR_JOINTS) { + const t = a4Token(side, joint) + if (have.has(t)) out.push(t) + } + } + return out.join(" ") +} + +/** Flip one cell's controller and return the new form value. */ +export function toggleA4Token(value: string | undefined, side: string, joint: string): string { + const tokens = parseA4Tokens(value) + const t = a4Token(side, joint) + if (tokens.has(t)) tokens.delete(t) + else tokens.add(t) + return serializeA4Tokens(tokens) +} + +/** + * The controller a joint runs on for this run: `a4` when the form names it + * or the robot's config already sets `wire_mode a4` (a run can add `--a4` + * joints on top of the config but not take one away), else `mit`. + */ +export function effectiveWireMode( + value: string | undefined, + configModes: Record> | null | undefined, + side: string, + joint: string +): "mit" | "a4" { + if (parseA4Tokens(value).has(a4Token(side, joint))) return "a4" + return (configModes?.[side]?.[joint] ?? "mit").toLowerCase() === "a4" ? "a4" : "mit" +} diff --git a/web/app/test/wire-mode.test.mjs b/web/app/test/wire-mode.test.mjs new file mode 100644 index 00000000..f8192cf0 --- /dev/null +++ b/web/app/test/wire-mode.test.mjs @@ -0,0 +1,36 @@ +import assert from "node:assert/strict" +import test from "node:test" + +import { + MYACTUATOR_JOINTS, + effectiveWireMode, + parseA4Tokens, + serializeA4Tokens, + toggleA4Token, +} from "../src/lib/wire-mode.ts" + +test("only MyActuator joints are offered the firmware loop", () => { + assert.deepEqual(MYACTUATOR_JOINTS, ["shoulder_1", "shoulder_2", "shoulder_3", "elbow", "wrist_1"]) +}) + +test("parse drops unknown tokens and serializes in a stable order", () => { + const tokens = parseA4Tokens("right.shoulder_1 left.wrist_2 bogus left.shoulder_1 right.elbow") + assert.deepEqual([...tokens].sort(), ["left.shoulder_1", "right.elbow", "right.shoulder_1"]) + assert.equal(serializeA4Tokens(tokens), "left.shoulder_1 right.shoulder_1 right.elbow") + assert.equal(serializeA4Tokens(parseA4Tokens(undefined)), "") +}) + +test("toggle adds then removes a cell", () => { + const on = toggleA4Token("", "right", "shoulder_1") + assert.equal(on, "right.shoulder_1") + assert.equal(toggleA4Token(on, "left", "shoulder_1"), "left.shoulder_1 right.shoulder_1") + assert.equal(toggleA4Token(on, "right", "shoulder_1"), "") +}) + +test("the config's wire_mode a4 counts as firmware even when the form is empty", () => { + const cfg = { right: { shoulder_1: "a4", shoulder_2: "mit" }, left: {} } + assert.equal(effectiveWireMode("", cfg, "right", "shoulder_1"), "a4") + assert.equal(effectiveWireMode("", cfg, "right", "shoulder_2"), "mit") + assert.equal(effectiveWireMode("", null, "left", "elbow"), "mit") + assert.equal(effectiveWireMode("left.elbow", null, "left", "elbow"), "a4") +}) From 0c2bdd127e078e9d2500f9db63259b1a6931221f Mon Sep 17 00:00:00 2001 From: Shawn Patel Date: Tue, 22 Sep 2026 01:00:00 +0000 Subject: [PATCH 15/80] tune.a4: write the planner acceleration before the mode-switch reset MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The right elbow (RMD-X6-P20, firmware 2025070202) did not move at all under tune.a4 while the X8-P20 shoulders (2026042402) tracked fine. Both runs homed and ramped to centre under the same 0xA4 command with the stored planner acceleration (5000), then the tool wrote 0 for the wave and the joint held its target for 12 s: reply speed 0 on every frame, position flat, current a steady gravity hold. The V4.3 and V4.4 protocol manuals both document 0 as direct PI tracking and their 0xA4 sections are identical, so this is an implementation gap, not an API change: on that firmware a 0 written into a running position loop is silently ignored, while the same 0 stored before the 0x76 reset the mode switch performs gives the documented direct tracking (0.23° RMS, 74 ms lag on a 3 deg/s triangle). Non-zero values do apply live there — 5000 → 60000 took effect mid-session — and the shoulders apply 0 live too, so writing the planner ahead of the reset is right for every firmware seen. RAM gains (0x31) stay where they were, after the reset, since it wipes them. The same session showed the protocol maximum, 60000 dps/s, tracks the elbow far better than direct tracking: the planner completes each 200 Hz step's plan inside the tick and follows the stream to 0.02° RMS with 4 ms lag, while 5000 never finishes a plan before the next target and the joint barely moves (3.2° RMS, 91 % stuck). The "will not follow" warning and the --accel help now name both working values; docs updated. Co-Authored-By: Claude Fable 5.1 --- almond_axol/cli/tune/a4.py | 96 ++++++++++++++++++++++++++------------ docs/cli/tune-a4.mdx | 4 +- 2 files changed, 67 insertions(+), 33 deletions(-) diff --git a/almond_axol/cli/tune/a4.py b/almond_axol/cli/tune/a4.py index fa26e86c..d52d7796 100644 --- a/almond_axol/cli/tune/a4.py +++ b/almond_axol/cli/tune/a4.py @@ -8,6 +8,16 @@ choose, reads the fine 0.01° position (0x92) every cycle, scores tracking and smoothness, and saves the run for the diagnostics dashboard. +Planner acceleration: ``0`` is the documented direct-tracking mode, and the +protocol maximum ``60000`` makes the planner finish each 200 Hz step inside +the tick — on the X6-P20 elbow the latter tracked a 3 deg/s triangle to +0.02° RMS with 4 ms lag against 0.23° / 74 ms for direct tracking. Values in +between re-plan every target and the joint barely moves. The value is +written *before* the mode-switch reset: on the elbow's 2025070202 firmware +a 0 written into a running position loop is silently ignored (the joint +holds and executes nothing), while the same 0 applied through the reset +works; non-zero values apply live on every firmware seen. + Why a separate tool: ``tune.pid`` tunes the MIT impedance frame, whose gains live in the host. Under 0xA4 the whole controller is the motor's own position PI → speed PI → current loop, so the knobs are the firmware gains @@ -75,6 +85,14 @@ _MA_WRITE_GAIN_ROM = 0x32 _FLASH_SETTLE_S = 0.3 +#: Planner acceleration (dps/s, the protocol maximum) at which the firmware +#: completes each 200 Hz step's plan inside the tick, so a re-planning +#: position loop follows the stream instead of stalling on it. On the right +#: elbow (X6-P20) this tracked a 3 deg/s triangle to 0.02° RMS with 4 ms lag +#: — better than direct tracking (accel 0: 0.23°, 74 ms) — while 5000 dps/s +#: never finished a plan before the next target and the joint barely moved. +_ACCEL_STEP_FOLLOW = 60000 + GAIN_NAMES: tuple[str, ...] = tuple(_MA_PID_IDX) #: Position error (rad) past which the wave is abandoned — the loop is not @@ -411,9 +429,12 @@ def add_parser(subparsers: argparse._SubParsersAction) -> None: # type: ignore[ "--accel", type=int, default=None, - help="Position-planner acceleration (dps/s) for the run: 0 = direct PI tracking of the " - "stream (required for it to follow at all); default: leave the stored value. Written to " - "ROM and restored afterwards unless --keep", + help="Position-planner acceleration (dps/s) for the run, written to ROM before the " + "mode-switch reset and restored afterwards unless --keep; default: leave the stored " + f"value. 0 = direct PI tracking of the stream; {_ACCEL_STEP_FOLLOW} (the protocol " + "maximum) = the planner completes each 200 Hz step within the tick, which tracked the " + "X6-P20 elbow better than 0 (0.02° vs 0.23° RMS). Anything in between re-plans every " + "target and will not follow the wave.", ) for name in GAIN_NAMES: p.add_argument( @@ -499,15 +520,7 @@ async def _run(args: argparse.Namespace) -> None: async with CanBus(channel) as bus: raw = {j: Motor(bus, j) for j in ARM_JOINTS} await asyncio.gather(*[m.enable() for m in raw.values()]) - motors = await joint_frame_motors(raw, is_left) - await asyncio.gather( - *[ - m.set_control_mode(ControlMode.POSITION_VELOCITY) - for m in motors.values() - ] - ) - motor = motors[joint] - driver = motor.motor._driver + driver = raw[joint]._driver if not isinstance(driver, MyActuatorMotor): raise SystemExit(f"{joint.value} is not a MyActuator joint") before_gains: dict[str, float] | None = None @@ -516,6 +529,44 @@ async def _run(args: argparse.Namespace) -> None: reason: str | None = None used_gains: dict[str, float] = {} accel_used: tuple[int, int] | None = None + + # Planner acceleration goes in *before* the mode switch below: that + # switch is a 0x76 reset, and the reset is what makes a planner value + # of 0 take effect. On the X6-P20 elbow (firmware 2025070202) a 0 + # written into a running position loop is ignored — the joint held + # its target and executed nothing for a whole run (2026-09-21) while + # the same 0 stored before the reset gave the documented direct + # tracking. Non-zero values do apply live on that firmware (5000 → + # 60000 took effect mid-session); the X8-P20 shoulders (2026042402) + # apply 0 live as well. Writing first is right for every one of them. + stored_accel = await _read_accel(driver) + print( + f" planner accel/decel stored: {stored_accel[0]}/{stored_accel[1]} dps/s" + ) + if args.accel is not None and stored_accel != (args.accel, args.accel): + before_accel = stored_accel + accel_used = await _write_accel(driver, args.accel, args.accel) + print( + f" planner accel/decel {stored_accel[0]}/{stored_accel[1]} → {accel_used[0]}/{accel_used[1]} dps/s" + ) + else: + accel_used = stored_accel + if accel_used[0] not in (0, _ACCEL_STEP_FOLLOW): + print( + f" ! planner acceleration is {accel_used[0]} dps/s: the firmware re-plans " + "every streamed target and will not follow the wave — pass --accel 0 " + f"(direct PI tracking) or --accel {_ACCEL_STEP_FOLLOW} (planner completes " + "each step within the tick)" + ) + + motors = await joint_frame_motors(raw, is_left) + await asyncio.gather( + *[ + m.set_control_mode(ControlMode.POSITION_VELOCITY) + for m in motors.values() + ] + ) + motor = motors[joint] try: print(" Homing all joints to rest ...") await _home_all(motors) @@ -528,25 +579,8 @@ async def _run(args: argparse.Namespace) -> None: await _ramp_verified(motors, {joint: center}) await asyncio.sleep(0.3) - # Planner and gains: written after every mode switch/homing is - # done (those reset the motor and reload ROM). - stored_accel = await _read_accel(driver) - print( - f" planner accel/decel stored: {stored_accel[0]}/{stored_accel[1]} dps/s" - ) - if args.accel is not None and stored_accel != (args.accel, args.accel): - before_accel = stored_accel - accel_used = await _write_accel(driver, args.accel, args.accel) - print( - f" planner accel/decel {stored_accel[0]}/{stored_accel[1]} → {accel_used[0]}/{accel_used[1]} dps/s" - ) - else: - accel_used = stored_accel - if accel_used[0] != 0: - print( - f" ! planner acceleration is {accel_used[0]} dps/s: the firmware re-plans " - "every streamed target and will not follow the wave — pass --accel 0" - ) + # Gains are written here, after the mode switch and homing: RAM + # gains (0x31) do not survive the reset those perform. stock = await _read_gains(driver) used_gains = {**stock, **requested} if requested: diff --git a/docs/cli/tune-a4.mdx b/docs/cli/tune-a4.mdx index 428e12d0..1358c866 100644 --- a/docs/cli/tune-a4.mdx +++ b/docs/cli/tune-a4.mdx @@ -5,7 +5,7 @@ description: "Tune a MyActuator joint's firmware position loop (0xA4) with a sin Tunes the **firmware** position loop of one MyActuator joint — the controller the realtime core hands a joint to when its `wire_mode` is `a4` (see the [config reference](/cli/configuration)). Streams a sine or a constant-speed triangle target over 0xA4 at `--rate` Hz with the firmware gains, 0xA4 speed cap and planner acceleration you choose, reads the fine 0.01° position (0x92) every cycle, scores tracking and creep smoothness, and saves the run for the diagnostics dashboard (Tuning → Firmware loop). Also available from the dashboard (`axol serve`). -[`tune.pid`](/cli/tune-pid) tunes the MIT impedance frame, whose gains are the host's. Under 0xA4 the whole controller is the motor's own position PI → speed PI → current loop, so the knobs here are the firmware gains (`position_kp/ki/kd`, `speed_kp/ki`, `current_kp/ki`), the speed cap, and the position planner's acceleration: **0 puts the loop in direct PI tracking of the stream; any other value re-plans every streamed target and the joint will not follow the wave at all.** +[`tune.pid`](/cli/tune-pid) tunes the MIT impedance frame, whose gains are the host's. Under 0xA4 the whole controller is the motor's own position PI → speed PI → current loop, so the knobs here are the firmware gains (`position_kp/ki/kd`, `speed_kp/ki`, `current_kp/ki`), the speed cap, and the position planner's acceleration: **0 puts the loop in direct PI tracking of the stream, and the protocol maximum 60000 makes the planner finish each 200 Hz step inside the tick — anything in between re-plans every streamed target and the joint will not follow the wave.** On the X6-P20 elbow 60000 tracked a 3 deg/s triangle to 0.02° RMS with 4 ms lag against 0.23° / 74 ms for direct tracking, so try both. The tool writes the planner **before** the mode-switch reset: on the elbow's 2025-07 firmware a 0 written into a running position loop is silently ignored (the joint holds and executes nothing), while the same 0 applied through the reset works. The triangle is the stick-slip probe: every pass runs at one creep speed, so the behaviour is not confined to the sine's turnarounds. Compare runs on **velocity ripple** (std of measured minus commanded velocity over the commanded speed: the MIT frame's stick-slip sits near 0.8, smooth is under 0.2), **stuck windows**, the **1–4 Hz error band**, **lag** and **>10 Hz buzz**. @@ -28,7 +28,7 @@ Safety, built in: | `--duration S` | Seconds of wave (default: 12) | | `--rate HZ` | Command rate (default: 200) | | `--cap DPS` | 0xA4 speed cap (default: 60) | -| `--accel DPS/S` | Planner acceleration for the run; `0` = direct tracking. Written to ROM, restored afterwards unless `--keep` | +| `--accel DPS/S` | Planner acceleration for the run, written to ROM before the mode-switch reset and restored afterwards unless `--keep`. `0` = direct PI tracking; `60000` = step-follow (see above); values in between will not follow a stream | | `--position-kp`, `--position-ki`, `--position-kd`, `--speed-kp`, `--speed-ki`, `--current-kp`, `--current-ki` | Firmware gains for the run (default: leave as is). The tool prints the motor's live values at start; the dashboard tab shows them next to each field and seeds its sliders there | | `--persist` | Write gains to ROM instead of RAM | | `--keep` | Leave the run's gains and planner acceleration in the motor | From 362d6fd0c44b7568a5431622e5ec34bef2b17b73 Mon Sep 17 00:00:00 2001 From: Shawn Patel Date: Tue, 22 Sep 2026 01:18:37 +0000 Subject: [PATCH 16/80] Firmware loop gains for the X6-P20 elbow: position_kp 0.2, speed 0.1 / 1e-5 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Same mechanism as the shoulders (written to ROM at enable, acts under wire_mode a4 only): position_kp 0.2 over the stock 0.15, position_kd left at the stock 0.1, and the shoulders' speed loop — speed_kp 0.1 (stock 0.01), speed_ki 1e-5. Both arms. Co-Authored-By: Claude Fable 5.1 --- almond_axol/robot/config.py | 13 ++++++++++++ docs/snippets/config/robot.mdx | 8 +++---- tests/test_firmware_gains.py | 39 ++++++++++++++++++++++++++++------ 3 files changed, 49 insertions(+), 11 deletions(-) diff --git a/almond_axol/robot/config.py b/almond_axol/robot/config.py index dab8312b..c37da1bd 100644 --- a/almond_axol/robot/config.py +++ b/almond_axol/robot/config.py @@ -364,6 +364,18 @@ class PositionForceConfig: speed_ki=1e-5, ) +# The X6-P20 elbow's set (its stock position_kp is 0.15, speed_kp 0.01 on +# firmware 2025070202): position_kp 0.2 with the shoulders' speed loop. +# Chosen on 2026-09-21 after the elbow first tracked under 0xA4 at stock +# gains — the earlier "elbow does not move" runs were the planner write, +# not the loop (see ``tune.a4``). Same ROM write at enable, same a4-only. +_X6_ELBOW_FIRMWARE_GAINS = FirmwareGains( + position_kp=0.2, + position_kd=0.1, + speed_kp=0.1, + speed_ki=1e-5, +) + @dataclass class ArmConfig: @@ -462,6 +474,7 @@ class ArmConfig: # active at 9.55 Hz. Hardware step/replay A/Bs found that term # increased overshoot without removing a ring; firmware kd=5 # settled the joint without the host-loop phase risk. + firmware=_X6_ELBOW_FIRMWARE_GAINS, ) ) wrist_1: JointConfig = field( diff --git a/docs/snippets/config/robot.mdx b/docs/snippets/config/robot.mdx index 851e5fcb..077d249a 100644 --- a/docs/snippets/config/robot.mdx +++ b/docs/snippets/config/robot.mdx @@ -32,11 +32,11 @@ export const F = ({ children }) => {child | --{prefix}left.elbow.friction.fv | per motor | Viscous friction (Nm·s/rad). | | --{prefix}left.elbow.friction.fo | per motor | Constant torque offset (Nm). | | --{prefix}left.elbow.friction.fl | `0.0` | Load-proportional Coulomb friction, Nm per Nm of gravity feed-forward: the effective `fc` is `fc + fl × |gravity|`. Planetary gear friction grows with the torque it carries (right shoulder_1: ~0.6 Nm sliding at rest, ~1.5 Nm under 12 Nm), so a constant `fc` over-compensates at rest and under-compensates at reach. `0` keeps the constant model. | -| --{prefix}left.elbow.firmware.position_kp | `null` (shoulders `0.3`) | **MyActuator firmware** position-loop proportional gain, written to the motor's ROM at enable when set (`null` leaves the motor's stored value). These five gains are the motor's own position PI → speed PI controller and only act under `wire_mode` `a4`; identify them with [`tune.a4`](/cli/tune-a4). Stock 0.008 on the X8-P20 shoulders stick-slips at creep speed; 0.3 is the knee where the stairs are gone and the loop has not started to buzz. | +| --{prefix}left.elbow.firmware.position_kp | `null` (shoulders `0.3`, elbow `0.2`) | **MyActuator firmware** position-loop proportional gain, written to the motor's ROM at enable when set (`null` leaves the motor's stored value). These five gains are the motor's own position PI → speed PI controller and only act under `wire_mode` `a4`; identify them with [`tune.a4`](/cli/tune-a4). Stock 0.008 on the X8-P20 shoulders stick-slips at creep speed; 0.3 is the knee where the stairs are gone and the loop has not started to buzz. | | --{prefix}left.elbow.firmware.position_ki | `null` | Firmware position-loop integral gain. Leave at the stock `0`: on top of the speed integrator it hunts around the target. | -| --{prefix}left.elbow.firmware.position_kd | `null` (shoulders `0.1`) | Firmware position-loop derivative gain (protocol V4.2+). The motor stores it, but the 0xA4 loop on the X8-P20 measured inert to it. | -| --{prefix}left.elbow.firmware.speed_kp | `null` (shoulders `0.1`) | Firmware speed-loop proportional gain: the 0xA4 loop's only damping term, and its buzz knob (0.15 doubled the >20 Hz current on shoulder_1). | -| --{prefix}left.elbow.firmware.speed_ki | `null` (shoulders `1e-05`) | Firmware speed-loop integral gain. Lower than the stock `1e-4` is smoother on a geared joint: the integrator winds up while the joint is stuck and dumps it at release. | +| --{prefix}left.elbow.firmware.position_kd | `null` (shoulders and elbow `0.1`) | Firmware position-loop derivative gain (protocol V4.2+). The motor stores it, but the 0xA4 loop on the X8-P20 measured inert to it. | +| --{prefix}left.elbow.firmware.speed_kp | `null` (shoulders and elbow `0.1`) | Firmware speed-loop proportional gain: the 0xA4 loop's only damping term, and its buzz knob (0.15 doubled the >20 Hz current on shoulder_1). | +| --{prefix}left.elbow.firmware.speed_ki | `null` (shoulders and elbow `1e-05`) | Firmware speed-loop integral gain. Lower than the stock `1e-4` is smoother on a geared joint: the integrator winds up while the joint is stuck and dumps it at release. | Per-arm and per-joint flags are shown for `left` / `elbow`. Swap in `right`, or any joint: `shoulder_1`, `shoulder_2`, `shoulder_3`, `elbow`, `wrist_1`, `wrist_2`, `wrist_3`. diff --git a/tests/test_firmware_gains.py b/tests/test_firmware_gains.py index 17c41e00..58cb03e4 100644 --- a/tests/test_firmware_gains.py +++ b/tests/test_firmware_gains.py @@ -27,9 +27,22 @@ def test_shoulders_carry_the_x8_firmware_gains_on_both_arms(self) -> None: self.assertEqual(joint.firmware.as_dict(), _X8) self.assertIsNone(joint.firmware.position_ki) + def test_elbow_carries_its_own_set_on_both_arms(self) -> None: + cfg = AxolConfig() + for arm in (cfg.left, cfg.right): + self.assertEqual( + arm.elbow.firmware.as_dict(), + { + "position_kp": 0.2, + "position_kd": 0.1, + "speed_kp": 0.1, + "speed_ki": 1e-5, + }, + ) + def test_other_joints_leave_the_motor_alone(self) -> None: arm = AxolConfig().left - for name in ("shoulder_3", "elbow", "wrist_1", "wrist_2", "wrist_3"): + for name in ("shoulder_3", "wrist_1", "wrist_2", "wrist_3"): self.assertEqual(getattr(arm, name).firmware.as_dict(), {}) def test_defaults_survive_the_stiffness_blend(self) -> None: @@ -154,21 +167,33 @@ async def asyncSetUp(self) -> None: patcher.start() self.addCleanup(patcher.stop) - async def test_cold_shoulders_get_the_config_gains_and_others_are_untouched( + async def test_cold_configured_joints_get_their_gains_and_others_are_untouched( self, ) -> None: - s1, s2, elbow = _FakeMotor(_stock()), _FakeMotor(_stock()), _FakeMotor(_stock()) - arm = _arm({Joint.SHOULDER_1: s1, Joint.SHOULDER_2: s2, Joint.ELBOW: elbow}) + s1, s2, elbow, s3 = (_FakeMotor(_stock()) for _ in range(4)) + arm = _arm( + { + Joint.SHOULDER_1: s1, + Joint.SHOULDER_2: s2, + Joint.ELBOW: elbow, + Joint.SHOULDER_3: s3, + } + ) with self.assertLogs("almond_axol.robot.axol", level="INFO") as logs: await apply_firmware_gains( - arm, [Joint.SHOULDER_1, Joint.SHOULDER_2, Joint.ELBOW] + arm, [Joint.SHOULDER_1, Joint.SHOULDER_2, Joint.ELBOW, Joint.SHOULDER_3] ) for motor in (s1, s2): self.assertAlmostEqual(motor.store[_MA_PID_IDX["position_kp"]], 0.3, 6) + self.assertAlmostEqual(elbow.store[_MA_PID_IDX["position_kp"]], 0.2, 6) + for motor in (s1, s2, elbow): self.assertAlmostEqual(motor.store[_MA_PID_IDX["speed_kp"]], 0.1, 6) self.assertAlmostEqual(motor.store[_MA_PID_IDX["speed_ki"]], 1e-5, 9) - self.assertEqual(elbow.writes, []) - self.assertEqual(sum("written to ROM" in m for m in logs.output), 2) + # position_kd 0.1 is already the stock value: read, never written. + self.assertNotIn(_MA_PID_IDX["position_kd"], [i for i, _ in motor.writes]) + # shoulder_3 has no firmware block configured. + self.assertEqual(s3.writes, []) + self.assertEqual(sum("written to ROM" in m for m in logs.output), 3) async def test_held_joints_are_not_in_the_list_so_nothing_is_written(self) -> None: s1 = _FakeMotor(_stock()) From de1fcf1984947a6f438d1718176f25570f779c75 Mon Sep 17 00:00:00 2001 From: Shawn Patel Date: Tue, 22 Sep 2026 01:27:11 +0000 Subject: [PATCH 17/80] tune.motion: verify the start pose before playback; name joints left behind MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The approach move was streamed and assumed. A joint that does not follow it — a --a4 joint whose stored planner acceleration is neither 0 nor 60000 barely moves — was silently left at rest and playback ran anyway: right elbow on 2026-09-21 started 23° short of the motion's first row and scored 85° RMS while the shoulders swung around a pose the motion never planned. Now a joint further than ~3° from the first row after the approach is named with its error, playback is skipped and the arm returns to rest; with --a4 in play the message points at the planner read. Co-Authored-By: Claude Fable 5.1 --- almond_axol/cli/tune/motion.py | 54 +++++++++++++++++++++++++++++++++ docs/cli/tune-motion.mdx | 2 +- tests/test_tune_motion_start.py | 35 +++++++++++++++++++++ 3 files changed, 90 insertions(+), 1 deletion(-) create mode 100644 tests/test_tune_motion_start.py diff --git a/almond_axol/cli/tune/motion.py b/almond_axol/cli/tune/motion.py index 75125520..e5128d4f 100644 --- a/almond_axol/cli/tune/motion.py +++ b/almond_axol/cli/tune/motion.py @@ -92,6 +92,35 @@ ] +#: A joint further than this (rad, ~3°) from the motion's first row after the +#: approach move did not follow it, and playback must not start from there. +_START_POSE_TOL = 0.05 + + +def start_pose_stragglers( + q_now: np.ndarray, + q_start: np.ndarray, + left_indices: np.ndarray, + right_indices: np.ndarray, + tol: float = _START_POSE_TOL, +) -> list[tuple[str, float]]: + """Joints not at the motion start pose: ``[(column, error_deg), ...]``. + + The approach move is streamed, not verified — a joint that will not + follow the stream (a ``--a4`` joint whose stored planner acceleration + is neither 0 nor 60000 barely moves, see ``tune.a4``) is silently left + at rest, and replaying from there scores garbage for that joint and + swings the others around a pose the motion never planned for. + """ + out: list[tuple[str, float]] = [] + for side, indices in (("left", left_indices), ("right", right_indices)): + for j, idx in zip(ARM_JOINTS, indices): + err = float(q_now[idx] - q_start[idx]) + if abs(err) > tol: + out.append((f"{side}.{j.value}", math.degrees(err))) + return out + + def _parse_gain_overrides(specs: list[str]) -> dict[tuple[str, str, str], float]: """Parse ``--gain [side.]joint.field=value`` into ``{(side, joint, field): v}``. @@ -599,6 +628,11 @@ async def execute( if contact is not None: raise _Contact(contact) await asyncio.sleep(0.5) + stragglers = start_pose_stragglers( + snapshot(axol), q_start, solver.left_indices, solver.right_indices + ) + if stragglers: + raise _NotAtStart(stragglers) print(f"Replaying {motion.duration:.1f} s of motion ...") contact = await execute( @@ -609,6 +643,19 @@ async def execute( ) if contact is not None: raise _Contact(contact) + except _NotAtStart as exc: + print( + "\n ! not at the motion start pose after the approach — playback " + "skipped: " + + ", ".join(f"{name} {err:+.1f}° off" for name, err in exc.stragglers) + ) + if args.a4: + print( + " a --a4 joint that did not follow the approach usually has a " + "stored planner acceleration that is neither 0 nor 60000 (read it " + "with scripts/fw_gains.py --id ; anything in between re-plans " + "every streamed target and barely moves)" + ) except _Contact as exc: joint, residual = exc.trip print( @@ -717,6 +764,13 @@ def __init__(self, trip: tuple[str, float]) -> None: self.trip = trip +class _NotAtStart(Exception): + """Internal: the approach left joints off the start pose; skip playback.""" + + def __init__(self, stragglers: list[tuple[str, float]]) -> None: + self.stragglers = stragglers + + def _load_motion_or_exit(name: str) -> ReferenceMotion: try: return load_motion(name) diff --git a/docs/cli/tune-motion.mdx b/docs/cli/tune-motion.mdx index eafeb07b..f2deaabd 100644 --- a/docs/cli/tune-motion.mdx +++ b/docs/cli/tune-motion.mdx @@ -7,7 +7,7 @@ Replays a [reference motion](/cli/motion-build) through the production Rust-core Every run persists a tuning-run artifact (full per-joint time series + metrics) under `~/.almond/diagnostics/tuning/` for charting and side-by-side comparison. -The arm moves to the motion's start and back to rest on collision-aware planned trajectories; a contact watchdog aborts playback if a sustained torque residual says the arm is pushing on something that isn't in the plan. +The arm moves to the motion's start and back to rest on collision-aware planned trajectories; a contact watchdog aborts playback if a sustained torque residual says the arm is pushing on something that isn't in the plan. Arrival at the start pose is checked before playback: a joint more than ~3° off is named and playback is skipped (the arm returns to rest). A `--a4` joint that fails this almost always has a stored planner acceleration that is neither 0 nor 60000 — read it with `scripts/fw_gains.py --id `; anything in between re-plans every streamed target and the joint barely moves (see [`tune.a4`](/cli/tune-a4)). | Flag | Description | |---|---| diff --git a/tests/test_tune_motion_start.py b/tests/test_tune_motion_start.py new file mode 100644 index 00000000..510a24d6 --- /dev/null +++ b/tests/test_tune_motion_start.py @@ -0,0 +1,35 @@ +"""``tune.motion`` refuses to replay from a pose the approach never reached.""" + +from __future__ import annotations + +import math +import unittest + +import numpy as np + +from almond_axol.cli.tune.motion import _START_POSE_TOL, start_pose_stragglers + +_LEFT = np.arange(0, 7) +_RIGHT = np.arange(7, 14) + + +class StartPoseStragglersTest(unittest.TestCase): + def test_all_joints_at_start_is_clean(self) -> None: + q = np.linspace(-1.0, 1.0, 14) + self.assertEqual(start_pose_stragglers(q, q.copy(), _LEFT, _RIGHT), []) + nudged = q + 0.5 * _START_POSE_TOL + self.assertEqual(start_pose_stragglers(nudged, q, _LEFT, _RIGHT), []) + + def test_names_the_joint_and_reports_the_error_in_degrees(self) -> None: + q_start = np.zeros(14) + q_now = q_start.copy() + q_now[7 + 3] = math.radians(23.5) # right.elbow left at rest, 23.5° short + q_now[2] = -math.radians(4.0) # left.shoulder_3 a little off the other way + out = start_pose_stragglers(q_now, q_start, _LEFT, _RIGHT) + self.assertEqual([n for n, _ in out], ["left.shoulder_3", "right.elbow"]) + self.assertAlmostEqual(out[1][1], 23.5, places=6) + self.assertAlmostEqual(out[0][1], -4.0, places=6) + + +if __name__ == "__main__": + unittest.main() From b79695bc138d47801db0ca0d6f4b2547214846e2 Mon Sep 17 00:00:00 2001 From: Shawn Patel Date: Tue, 22 Sep 2026 02:04:34 +0000 Subject: [PATCH 18/80] Reset a motor after writing its firmware gains so the loop loads them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First enable after the elbow's firmware gains landed in config: the hook wrote position_kp / speed_kp / speed_ki to the right elbow's ROM straight after the core's prep reset, the core then streamed 0xA4 to it, and the joint held its pose for the whole slow_osc replay (2026-09-21, 23° short of the start row). The shoulders — already matching config from earlier --persist writes, so nothing written — tracked. On the X6-P20's 2025070202 firmware a 0x32 ROM write does not reach the running loop until the motor reboots; the same elbow, read back with those gains in ROM and the planner at 0, is exactly the state its stored-0 tune.a4 run tracked from. MyActuatorMotor.reset() is the 0x76 + settle that set_control_mode already did; apply_firmware_gains calls it on every motor it wrote (none on a provisioned motor), and the realtime bring-up now provisions *before* resolving the multi-turn offsets, since the reset re-derives them. Co-Authored-By: Claude Fable 5.1 --- almond_axol/motor/myactuator.py | 16 ++++++++++++++-- almond_axol/robot/axol.py | 13 ++++++++++++- almond_axol/rt/robot.py | 7 +++++-- tests/test_axol_construction.py | 8 ++++++++ tests/test_firmware_gains.py | 15 +++++++++++++++ 5 files changed, 54 insertions(+), 5 deletions(-) diff --git a/almond_axol/motor/myactuator.py b/almond_axol/motor/myactuator.py index c420b0c9..f77bb58c 100644 --- a/almond_axol/motor/myactuator.py +++ b/almond_axol/motor/myactuator.py @@ -493,12 +493,24 @@ async def get_model(self) -> str | None: async def disable(self) -> None: await self._request(self._cmd(_MA_SHUTDOWN)) + async def reset(self) -> None: + """0x76 system reset and the settle the motor needs before it answers. + + Reboots the motor: torque drops, RAM state (0x31 gains) is lost, and + the ROM parameters — loop gains written with 0x32, the planner + acceleration — are (re)loaded. On the X6-P20's 2025070202 firmware a + 0x32 write does not reach the running loop without this: the right + elbow provisioned at enable and streamed straight after held its + pose through a whole replay (2026-09-21), and tracked once rebooted. + """ + await self._bus._send(_MA_REQ + self._motor_id, self._cmd(_MA_RESET)) + await asyncio.sleep(_MA_RESET_SETTLE_S) + async def set_control_mode(self, mode: ControlMode) -> None: # MyActuator has no persistent control mode register; the active mode is # determined by which command is sent. Reset the motor to clear internal # state so it comes back ready for the next command type. - await self._bus._send(_MA_REQ + self._motor_id, self._cmd(_MA_RESET)) - await asyncio.sleep(_MA_RESET_SETTLE_S) + await self.reset() async def clear_errors(self) -> None: pass # MyActuator has no clear-errors command diff --git a/almond_axol/robot/axol.py b/almond_axol/robot/axol.py index 619f5297..98fa5f27 100644 --- a/almond_axol/robot/axol.py +++ b/almond_axol/robot/axol.py @@ -168,6 +168,15 @@ async def apply_firmware_gains(arm: "AxolArm", joints: Iterable[Joint]) -> None: the bus is quiet: a MyActuator commits a ROM write only when disabled, and joints found holding from a previous session are never touched. + A motor that took a write is **reset** afterwards (0x76, ~2 s): on the + X6-P20's 2025070202 firmware a ROM gain write does not reach the running + loop until the motor reboots — the right elbow provisioned here and then + streamed to on the firmware loop held its pose for a whole replay + (2026-09-21). The reset costs nothing on a provisioned motor (no write, + no reset) and only happens while the joint is already disabled. Callers + that derive anything from the motor's post-reset state (multi-turn + offsets) must do so after this returns. + A joint that will not take the write (pre-V4.2 firmware, no answer, or a read-back mismatch) keeps its stored gains and is logged as a warning — it tracks with whatever the motor holds, which is safe, just not the @@ -208,11 +217,13 @@ async def apply_firmware_gains(arm: "AxolArm", joints: Iterable[Joint]) -> None: continue if changed: _logger.info( - "%s.%s: firmware loop gains written to ROM: %s", + "%s.%s: firmware loop gains written to ROM: %s — resetting the " + "motor so the loop loads them", side, joint.value, ", ".join(f"{n} {b:g} -> {a:g}" for n, (b, a) in changed.items()), ) + await driver.reset() async def _rollback_newly_enabled_motors( diff --git a/almond_axol/rt/robot.py b/almond_axol/rt/robot.py index 40126464..04e093d0 100644 --- a/almond_axol/rt/robot.py +++ b/almond_axol/rt/robot.py @@ -488,12 +488,15 @@ async def _enable(self) -> None: self._enable_cold = cold for side, arm in self._arms(): - await arm.resolve_joint_offsets() # The configured firmware loop gains (wire_mode a4's controller) # go to ROM now: the cold joints have just been reset by prep and # are disabled, which is the only state a MyActuator commits a - # ROM write in, and the bus is quiet. Held joints are skipped. + # ROM write in, and the bus is quiet. Held joints are skipped. A + # motor that took a write is reset again so its loop loads the + # new gains — hence this runs *before* the offsets are resolved + # from the (post-reset) multi-turn reading. await apply_firmware_gains(arm, cold_joints.get(side, [])) + await arm.resolve_joint_offsets() # Python never calls Motor.enable() in production control, so run the # MyActuator capability detection (position/torque decode ranges) # and undervoltage provisioning explicitly. Otherwise passive diff --git a/tests/test_axol_construction.py b/tests/test_axol_construction.py index 699fe958..861af7e3 100644 --- a/tests/test_axol_construction.py +++ b/tests/test_axol_construction.py @@ -347,6 +347,14 @@ def setUp(self) -> None: joint: self.enterContext(patch.object(motor, "disable", AsyncMock())) for joint, motor in self.arm.motors.items() } + # The enable-time firmware-gain provisioning talks to the motors over + # the (mocked) bus like the other bring-up writes stubbed below. + self.enterContext( + patch( + "almond_axol.motor.myactuator.MyActuatorMotor.ensure_rom_gains", + AsyncMock(return_value={}), + ) + ) def _assert_only_cold_joints_torqued_off(self) -> None: for joint, disable in self.disables.items(): diff --git a/tests/test_firmware_gains.py b/tests/test_firmware_gains.py index 58cb03e4..4c8ec648 100644 --- a/tests/test_firmware_gains.py +++ b/tests/test_firmware_gains.py @@ -71,6 +71,10 @@ def __init__(self, store: dict[int, float], *, enabled: bool = False) -> None: self.store = store self.enabled = enabled self.writes: list[tuple[int, float]] = [] + self.resets = 0 + + async def reset(self) -> None: # type: ignore[override] + self.resets += 1 async def _request(self, data: bytes, *args, **kwargs) -> bytes: # type: ignore[override] cmd, index = data[0], data[1] @@ -194,6 +198,17 @@ async def test_cold_configured_joints_get_their_gains_and_others_are_untouched( # shoulder_3 has no firmware block configured. self.assertEqual(s3.writes, []) self.assertEqual(sum("written to ROM" in m for m in logs.output), 3) + # Every motor that took a write is rebooted so its loop loads the new + # gains; the untouched one is not. + self.assertEqual([s1.resets, s2.resets, elbow.resets, s3.resets], [1, 1, 1, 0]) + + async def test_a_provisioned_motor_is_neither_written_nor_reset(self) -> None: + s1 = _FakeMotor(_stock()) + await apply_firmware_gains(_arm({Joint.SHOULDER_1: s1}), [Joint.SHOULDER_1]) + s1.writes.clear() + s1.resets = 0 + await apply_firmware_gains(_arm({Joint.SHOULDER_1: s1}), [Joint.SHOULDER_1]) + self.assertEqual((s1.writes, s1.resets), ([], 0)) async def test_held_joints_are_not_in_the_list_so_nothing_is_written(self) -> None: s1 = _FakeMotor(_stock()) From f01201ec79e0f64efcd3e9184c20102c74364454 Mon Sep 17 00:00:00 2001 From: Shawn Patel Date: Tue, 22 Sep 2026 03:26:17 +0000 Subject: [PATCH 19/80] axol-rt: keep a4 joints on the 0xA4 frame through holds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An a4 joint got MIT frames for every passthrough tick — the core's bring-up hold, Python's hold-at-measured-pose, a stalled stream — and 0xA4 only once tracking started. The X6-P20's 2025070202 firmware refuses that: after an MIT frame it ignores 0xA4 until the motor is reset. The right elbow held its pose through two whole slow_osc replays on 2026-09-21 (22.5° short of the start row, nothing written to it the second time) while the X8-P20 shoulders, whose 2026042402 firmware switches freely, tracked beside it. tune.a4 never showed it because after its own reset it sends nothing but 0xA4 — the same elbow tracked there. `a4_wire` now picks 0xA4 for an a4 MyActuator on every tick with a position gain, tracked or holding, so the joint sees the position frame from its first command. Limp and gravity comp (kp = 0) stay MIT — they exist to make the joint compliant — so on the older firmware a hand-guided a4 joint needs a re-enable before it tracks again; documented on wire_mode. tune.motion's start-pose check now judges only the arms actually driven (an arm left off with --arms reads as rest and was reported as off) and points at both the planner and a stale core build. Co-Authored-By: Claude Fable 5.1 --- almond_axol/cli/tune/motion.py | 30 ++++++++++++++-------- almond_axol/robot/config.py | 18 +++++++++----- docs/snippets/config/robot.mdx | 2 +- rust/axol-rt/src/bringup.rs | 7 ++++-- rust/axol-rt/src/serve.rs | 44 ++++++++++++++++++++++++++++----- tests/test_tune_motion_start.py | 17 ++++++++++--- 6 files changed, 90 insertions(+), 28 deletions(-) diff --git a/almond_axol/cli/tune/motion.py b/almond_axol/cli/tune/motion.py index e5128d4f..cf30ccc9 100644 --- a/almond_axol/cli/tune/motion.py +++ b/almond_axol/cli/tune/motion.py @@ -100,12 +100,14 @@ def start_pose_stragglers( q_now: np.ndarray, q_start: np.ndarray, - left_indices: np.ndarray, - right_indices: np.ndarray, + arms: list[tuple[str, np.ndarray]], tol: float = _START_POSE_TOL, ) -> list[tuple[str, float]]: """Joints not at the motion start pose: ``[(column, error_deg), ...]``. + ``arms`` lists the arms actually driven, ``(side, full-N indices)`` — + an arm left off with ``--arms`` reads as rest and must not be judged. + The approach move is streamed, not verified — a joint that will not follow the stream (a ``--a4`` joint whose stored planner acceleration is neither 0 nor 60000 barely moves, see ``tune.a4``) is silently left @@ -113,7 +115,7 @@ def start_pose_stragglers( swings the others around a pose the motion never planned for. """ out: list[tuple[str, float]] = [] - for side, indices in (("left", left_indices), ("right", right_indices)): + for side, indices in arms: for j, idx in zip(ARM_JOINTS, indices): err = float(q_now[idx] - q_start[idx]) if abs(err) > tol: @@ -628,9 +630,15 @@ async def execute( if contact is not None: raise _Contact(contact) await asyncio.sleep(0.5) - stragglers = start_pose_stragglers( - snapshot(axol), q_start, solver.left_indices, solver.right_indices - ) + driven = [ + (side, idx) + for side, arm, idx in ( + ("left", axol.left, solver.left_indices), + ("right", axol.right, solver.right_indices), + ) + if arm is not None + ] + stragglers = start_pose_stragglers(snapshot(axol), q_start, driven) if stragglers: raise _NotAtStart(stragglers) @@ -651,10 +659,12 @@ async def execute( ) if args.a4: print( - " a --a4 joint that did not follow the approach usually has a " - "stored planner acceleration that is neither 0 nor 60000 (read it " - "with scripts/fw_gains.py --id ; anything in between re-plans " - "every streamed target and barely moves)" + " a --a4 joint that did not follow the approach: check its stored " + "planner acceleration (scripts/fw_gains.py --id ; 0 or 60000 " + "follow a stream, anything in between barely moves) and that the " + "realtime core is built from a checkout that holds a4 joints on the " + "position frame (the X6-P20's 2025-07 firmware ignores 0xA4 after " + "an MIT frame until reset)" ) except _Contact as exc: joint, residual = exc.trip diff --git a/almond_axol/robot/config.py b/almond_axol/robot/config.py index c37da1bd..b3c2f386 100644 --- a/almond_axol/robot/config.py +++ b/almond_axol/robot/config.py @@ -264,14 +264,20 @@ class JointConfig: dither_hz: Dither frequency; above the arm's structural modes (~35 Hz), below the core's 120 Hz Nyquist. wire_mode: Which frame the realtime core commands this joint with - while tracking (MyActuator joints only; Damiao joints, - the gripper, gravity comp and the limp fallback always use - MIT). ``"mit"`` (default) is the impedance frame and the + (MyActuator joints only; Damiao joints, the gripper, + gravity comp and the limp fallback always use MIT). + ``"mit"`` (default) is the impedance frame and the production law. ``"a4"`` hands the joint to the firmware's own position loop (0xA4 absolute position closed-loop, - speed-capped at the tracker's velocity limit): its kHz - position/speed PI on the motor-side encoder is the - candidate for creeping through the X8-P20's stick-slip. + speed-capped at the tracker's velocity limit) from its + first frame, holds included — the X6-P20's 2025070202 + firmware ignores 0xA4 after an MIT frame until the motor + is reset, so an a4 joint hand-guided in gravity comp needs + a re-enable before it tracks again. Its position/speed PI + on the motor-side encoder is the candidate for creeping + through the X8-P20's stick-slip; its gains are the + ``firmware`` block below and its stored planner + acceleration must be 0 or 60000 (see ``tune.a4``). Costs: no compliance (the joint holds position with integral action and pushes back up to motor torque), no host feedforward (gravity, friction, stiction, dither and diff --git a/docs/snippets/config/robot.mdx b/docs/snippets/config/robot.mdx index 077d249a..2d61cb7d 100644 --- a/docs/snippets/config/robot.mdx +++ b/docs/snippets/config/robot.mdx @@ -24,7 +24,7 @@ export const F = ({ children }) => {child | --{prefix}left.elbow.stribeck_load_gain | `0.1` | Growth of that excess per Nm of gravity feed-forward (right shoulder_1: ~1.5 Nm under 12 Nm). | | --{prefix}left.elbow.stribeck_vs | `0.1` | Speed (rad/s) at which the excess has fallen to 1/e. | | --{prefix}left.elbow.stribeck_pole | `20.0` | Low-pass pole (rad/s) of the measured velocity the term follows. 20 is smooth but ~40° behind a 2.6 Hz surge; 40–80 follows it at the cost of encoder-step noise in the torque. | -| --{prefix}left.elbow.wire_mode | `mit` | Frame the realtime core commands a **MyActuator** joint with while tracking. `mit` is the impedance frame (production). `a4` hands the joint to the firmware's own position loop (0xA4, speed-capped at the tracker limit) with a paired 0x92 read for 0.01° position — the candidate for creeping through X8-P20 stick-slip. Costs: no compliance, no host feed-forward, and NaN torque telemetry (the contact watchdog is blind on that joint). Damiao joints, the gripper, gravity comp and the limp fallback always use MIT. | +| --{prefix}left.elbow.wire_mode | `mit` | Frame the realtime core commands a **MyActuator** joint with while tracking. `mit` is the impedance frame (production). `a4` hands the joint to the firmware's own position loop (0xA4, speed-capped at the tracker limit) with a paired 0x92 read for 0.01° position — the candidate for creeping through X8-P20 stick-slip. The joint is on 0xA4 from its first frame, holds included: the X6-P20's 2025-07 firmware ignores 0xA4 after an MIT frame until the motor is reset (the 2026 X8-P20 firmware switches freely). Costs: no compliance, no host feed-forward, and NaN torque telemetry (the contact watchdog is blind on that joint). Damiao joints, the gripper, gravity comp and the limp fallback always use MIT — so on the older firmware an a4 joint that has been hand-guided needs a re-enable before it tracks again. The joint's stored planner acceleration must be 0 or 60000 (see [`tune.a4`](/cli/tune-a4)); its loop gains come from `firmware.*` below. | | --{prefix}left.elbow.mass | per joint | Mass (kg) of the link, used for gravity compensation. Tune if an arm sags or pushes back in [gravity comp](/operations/gravity-comp). | | --{prefix}left.elbow.com | per joint | Centre of mass `[x, y, z]` (m) of the link. | | --{prefix}left.elbow.friction.fc | per motor | Coulomb friction (Nm). Measured per motor with [`tune.friction`](/cli/tune-friction). | diff --git a/rust/axol-rt/src/bringup.rs b/rust/axol-rt/src/bringup.rs index 21219572..a9f632ca 100644 --- a/rust/axol-rt/src/bringup.rs +++ b/rust/axol-rt/src/bringup.rs @@ -73,8 +73,11 @@ pub enum Vendor { } /// Which frame a MyActuator arm joint is commanded with in tracked mode. -/// Damiao joints, the gripper, and every passthrough/limp tick use MIT -/// regardless. +/// Damiao joints, the gripper, and every limp / gravity-comp tick (kp = 0) +/// use MIT regardless. An a4 joint's *holds* (bring-up, stalled stream) are +/// 0xA4 too: the X6-P20's 2025070202 firmware ignores 0xA4 after an MIT +/// frame until reset, so an a4 joint must see the position frame from its +/// first tick (see `serve::a4_wire`). #[derive(Clone, Copy, PartialEq, Debug)] pub enum WireMode { /// The 0x400 impedance frame: the production control law. diff --git a/rust/axol-rt/src/serve.rs b/rust/axol-rt/src/serve.rs index c386faba..3db1d280 100644 --- a/rust/axol-rt/src/serve.rs +++ b/rust/axol-rt/src/serve.rs @@ -572,6 +572,25 @@ fn go_limp(limp: &AtomicBool, out_tx: &mpsc::Sender>, reason: &str) -> b true } +/// Whether this tick's frame for a joint is the 0xA4 position command. +/// +/// Only MyActuator joints on `wire_mode a4`, and for them on every tick that +/// commands a position: tracked ticks *and* passthrough holds (bring-up +/// hold, a stalled stream, Python's hold-at-measured-pose), which is any +/// tick with a position gain. The alternative — MIT for the hold, 0xA4 once +/// tracking starts — is what the classic design did, and it is exactly what +/// the X6-P20's 2025070202 firmware refuses: after an MIT frame, 0xA4 is +/// ignored until the motor is reset. The right elbow held its pose through +/// two whole replays that way (2026-09-21) while the X8-P20 shoulders, whose +/// 2026042402 firmware switches freely, tracked. Keeping an a4 joint on 0xA4 +/// from its first frame works on both. Limp and gravity comp (`kp == 0`) +/// stay MIT: those exist to make the joint compliant, which the firmware +/// position loop cannot be — so on the older firmware a joint that has been +/// hand-guided needs a re-enable before it will track on a4 again. +fn a4_wire(vendor: Vendor, wire: WireMode, tracked: bool, kp: f64) -> bool { + vendor == Vendor::MyActuator && wire == WireMode::A4 && (tracked || kp > 0.0) +} + #[derive(Clone, Copy, Debug, Default)] pub struct JointCmd { pub p_des: f64, @@ -1236,6 +1255,19 @@ mod tests { assert_eq!(consecutive.record(LATE, PERIOD), TimingVerdict::Degraded); } + #[test] + fn a4_joints_stay_on_the_position_frame_through_holds_but_not_limp() { + use crate::bringup::{Vendor, WireMode}; + // Tracked and holding (kp > 0) both take 0xA4 on an a4 MyActuator … + assert!(a4_wire(Vendor::MyActuator, WireMode::A4, true, 250.0)); + assert!(a4_wire(Vendor::MyActuator, WireMode::A4, false, 250.0)); + // … limp / gravity comp (kp = 0) fall back to MIT for compliance … + assert!(!a4_wire(Vendor::MyActuator, WireMode::A4, false, 0.0)); + // … and nothing else ever does, whatever the tick. + assert!(!a4_wire(Vendor::MyActuator, WireMode::Mit, true, 250.0)); + assert!(!a4_wire(Vendor::Damiao, WireMode::A4, true, 250.0)); + } + #[test] fn timing_health_isolated_overrun_degrades_not_limps() { // The field record: one 60 ms stall in an otherwise perfect stream. @@ -2634,12 +2666,12 @@ fn bus_loop( fb_dt: f64::NAN, }); } - if tracked && m.vendor == Vendor::MyActuator && m.wire == WireMode::A4 { - // Firmware position loop: the streamed trajectory as - // an absolute 0.01° target under the tracker's own - // velocity limit as the speed cap. No feedforward - // reaches the wire; the 0x92 read below restores - // fine position to the host. + if a4_wire(m.vendor, m.wire, tracked, c.kp) { + // Firmware position loop: the streamed trajectory (or + // the hold pose) as an absolute 0.01° target under the + // tracker's own velocity limit as the speed cap. No + // feedforward reaches the wire; the 0x92 read below + // restores fine position to the host. a4_follow[motor_index] = true; ( proto::MA_REQ + m.id as u16, diff --git a/tests/test_tune_motion_start.py b/tests/test_tune_motion_start.py index 510a24d6..182045d2 100644 --- a/tests/test_tune_motion_start.py +++ b/tests/test_tune_motion_start.py @@ -11,25 +11,36 @@ _LEFT = np.arange(0, 7) _RIGHT = np.arange(7, 14) +_BOTH = [("left", _LEFT), ("right", _RIGHT)] class StartPoseStragglersTest(unittest.TestCase): def test_all_joints_at_start_is_clean(self) -> None: q = np.linspace(-1.0, 1.0, 14) - self.assertEqual(start_pose_stragglers(q, q.copy(), _LEFT, _RIGHT), []) + self.assertEqual(start_pose_stragglers(q, q.copy(), _BOTH), []) nudged = q + 0.5 * _START_POSE_TOL - self.assertEqual(start_pose_stragglers(nudged, q, _LEFT, _RIGHT), []) + self.assertEqual(start_pose_stragglers(nudged, q, _BOTH), []) def test_names_the_joint_and_reports_the_error_in_degrees(self) -> None: q_start = np.zeros(14) q_now = q_start.copy() q_now[7 + 3] = math.radians(23.5) # right.elbow left at rest, 23.5° short q_now[2] = -math.radians(4.0) # left.shoulder_3 a little off the other way - out = start_pose_stragglers(q_now, q_start, _LEFT, _RIGHT) + out = start_pose_stragglers(q_now, q_start, _BOTH) self.assertEqual([n for n, _ in out], ["left.shoulder_3", "right.elbow"]) self.assertAlmostEqual(out[1][1], 23.5, places=6) self.assertAlmostEqual(out[0][1], -4.0, places=6) + def test_an_arm_left_off_is_not_judged(self) -> None: + # ``--arms right``: the left arm reads as rest while the motion's + # first row is elsewhere — that is not a straggler. + q_start = np.linspace(-1.0, 1.0, 14) + q_now = q_start.copy() + q_now[:7] = 0.0 + q_now[7 + 3] += math.radians(22.5) + out = start_pose_stragglers(q_now, q_start, [("right", _RIGHT)]) + self.assertEqual(out, [("right.elbow", 22.5)]) + if __name__ == "__main__": unittest.main() From bb3d50b1f12cb0da65035455793ccc2bce0d3607 Mon Sep 17 00:00:00 2001 From: Shawn Patel Date: Tue, 22 Sep 2026 03:36:18 +0000 Subject: [PATCH 20/80] Dashboard: firmware-loop runs re-arm the Firmware-loop tab with their settings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit tune.a4 saves its runs as kind "sine" (they share the sine/triangle charts) tagged wire "a4", and the workbench keyed the run-click re-arm on kind alone: clicking one switched to the impedance Sine tab and filled nothing. Runs tagged a4 now open the Firmware-loop tab with the arm, joint, wave, centre, half-travel, speed/frequency, duration, rate, speed cap, planner acceleration, the five firmware gains and the persist flag that produced them, show "a4" in the badges, and score on tune.a4's creep card (tracking, lag, velocity ripple, stuck fraction, 1–4 Hz band, buzz, current) with its legend instead of the impedance score. Co-Authored-By: Claude Fable 5.1 --- .../diagnostics/tuning-workbench.tsx | 79 +++++++++++++++++-- 1 file changed, 74 insertions(+), 5 deletions(-) diff --git a/web/app/src/components/diagnostics/tuning-workbench.tsx b/web/app/src/components/diagnostics/tuning-workbench.tsx index ca86092a..71f55fc0 100644 --- a/web/app/src/components/diagnostics/tuning-workbench.tsx +++ b/web/app/src/components/diagnostics/tuning-workbench.tsx @@ -684,6 +684,27 @@ const KIND_TABS: Record = { kinematics: "ik", } +/** + * A `tune.a4` run: saved as kind `sine` (it shares the sine/triangle charts) + * but tagged `wire: "a4"` — it belongs to the Firmware-loop tab, carries the + * firmware gains instead of impedance gains, and is scored on creep + * smoothness rather than the impedance score. + */ +function isA4Run(meta: TuningRunMeta): boolean { + return meta.kind === "sine" && meta.params?.wire === "a4" +} + +/** The launcher tab a saved run re-arms, or null for kinds without one. */ +function runTab(meta: TuningRunMeta): string | null { + if (isA4Run(meta)) return "a4" + return KIND_TABS[meta.kind] ?? null +} + +/** What to call a run in badges: its kind, except firmware-loop runs. */ +function runKindLabel(meta: TuningRunMeta): string { + return isA4Run(meta) ? "a4" : meta.kind +} + /* ------------------------------------------------------------------ */ /* Gain-override editor (Recorded motion tab) */ /* ------------------------------------------------------------------ */ @@ -1100,6 +1121,25 @@ function runFormValues(meta: TuningRunMeta): Record | null { out[key] = v } } + if (isA4Run(meta)) { + put("arm", meta.side) + put("joint", meta.joint) + put("mode", p.mode) + put("center", p.center_deg) + put("amp", p.amp_deg) + put("speed", p.speed_dps) + put("freq", p.freq_hz) + put("duration", p.duration_s) + put("rate", p.rate_hz) + put("cap", p.cap_dps) + if (Array.isArray(p.accel) && typeof p.accel[0] === "number") out["accel"] = String(p.accel[0]) + for (const k of ["position_kp", "position_ki", "position_kd", "speed_kp", "speed_ki"]) { + const v = g[k] + if (typeof v === "number" && Number.isFinite(v)) out[k] = fmtFwGain(v) + } + if (p.persist === true) out["persist"] = "true" + return out + } switch (meta.kind) { case "sine": case "step": @@ -1782,6 +1822,21 @@ const SINE_COLS: ScoreCol[] = [ { key: "score", label: "score", digits: 3 }, ] +// tune.a4's creep-smoothness scorecard (see a4_metrics): the MIT frame's +// stick-slip sits near 0.8 velocity ripple, smooth is under 0.2. +const A4_COLS: ScoreCol[] = [ + { key: "rms", label: "tracking RMS °", deg: true, digits: 3, warn: 0.5, bad: 2.0 }, + { key: "max", label: "max err °", deg: true, digits: 3, warn: 1.5, bad: 5 }, + { key: "lag_ms", label: "lag ms", digits: 0, warn: 100, bad: 300 }, + { key: "v_ripple", label: "vel ripple", digits: 2, warn: 0.3, bad: 0.8 }, + { key: "stuck_frac", label: "stuck", digits: 2, warn: 0.05, bad: 0.3 }, + { key: "band_1_4", label: "1–4 Hz °", deg: true, digits: 3, warn: 0.1, bad: 0.3 }, + { key: "buzz", label: ">10 Hz buzz °", deg: true, digits: 3, warn: 0.02, bad: 0.1 }, + { key: "iq_rms", label: "current RMS A", digits: 2 }, + { key: "iq_max", label: "peak A", digits: 1 }, + { key: "hz", label: "loop Hz", digits: 0 }, +] + const FILTER_COLS: ScoreCol[] = [ { key: "input_rms", label: "noise in °", deg: true, digits: 3 }, { key: "rms_err", label: "error out °", deg: true, digits: 3 }, @@ -1826,6 +1881,14 @@ const STEP_COLS: ScoreCol[] = [ { key: "score", label: "score", digits: 3 }, ] +const A4_LEGEND = + "firmware position loop (0xA4). tracking RMS / max / lag = how the stream was " + + "followed. vel ripple = std of measured minus commanded velocity over the " + + "commanded speed — the MIT frame's stick-slip sits near 0.8, smooth is under 0.2. " + + "stuck = fraction of the pass with the joint not moving. 1–4 Hz = the stick-slip " + + "band in the error; >10 Hz buzz = high-frequency position motion (the current " + + "columns show a speed-loop buzz the 0.01° position read cannot)." + const SCORE_LEGEND: Record = { motion: "tracking RMS = average distance from the commanded joint position " + @@ -1909,7 +1972,13 @@ function scoreRows( } if (meta.kind === "sine" || meta.kind === "step" || meta.kind === "gravity") { return { - cols: meta.kind === "sine" ? SINE_COLS : meta.kind === "step" ? STEP_COLS : GRAVITY_COLS, + cols: isA4Run(meta) + ? A4_COLS + : meta.kind === "sine" + ? SINE_COLS + : meta.kind === "step" + ? STEP_COLS + : GRAVITY_COLS, rows: [{ joint: meta.joint ?? "joint", values: m }], } } @@ -2234,7 +2303,7 @@ export function TuningWorkbench({ (meta: TuningRunMeta) => { select(meta.id) const form = runFormValues(meta) - const tabFor = KIND_TABS[meta.kind] + const tabFor = runTab(meta) if (!form || !tabFor) return setTabKey(tabFor) setValues((prev) => ({ ...prev, [tabFor]: form })) @@ -2423,7 +2492,7 @@ export function TuningWorkbench({ return single ? [single] : [] }, [run, arm, armed]) const scores = meta ? scoreRows(meta, armed ? arm : null) : null - const legend = meta ? SCORE_LEGEND[meta.kind] : null + const legend = meta ? (isA4Run(meta) ? A4_LEGEND : SCORE_LEGEND[meta.kind]) : null const perJoint = (meta?.metrics as Record | undefined)?.per_joint as | Record> | undefined @@ -2946,7 +3015,7 @@ export function TuningWorkbench({ {/* Selected run: what it is, arm tabs, per-joint graphs, scores. */} {!comparing && meta && (
- {meta.kind} + {runKindLabel(meta)} {meta.joint ? `${meta.side} ${meta.joint}` : ""} {meta.params.motion ? `${meta.params.motion as string}` : ""} @@ -3132,7 +3201,7 @@ export function TuningWorkbench({ {cmpIdx === 0 ? "A" : "B"} )} - {r.kind} + {runKindLabel(r)} {[ r.side, From 3c2e3e7347d5125551c35fc254f43b54470bc001 Mon Sep 17 00:00:00 2001 From: Shawn Patel Date: Tue, 22 Sep 2026 03:56:41 +0000 Subject: [PATCH 21/80] tune.a4: --cap-track makes the 0xA4 speed cap follow the commanded speed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With the planner at 60000 dps/s and the fixed 60 dps cap, each 200 Hz target is a 0.015° step (at 3 deg/s) that the planner covers in ~0.5 ms at the cap and then idles for the rest of the tick: bursts at twenty times the commanded speed, 5 % duty cycle. On the right elbow that is what the 0.019° RMS / 4 ms lag came with — current spread 1.28 A against 0.33 A for direct tracking, 0.76 A above 20 Hz, 68–82 Hz velocity content, peak 6.5 A vs 4.3 A. The 0xA4 frame carries its cap per command, so `--cap-track K` sets it to K × |commanded speed| each sample (floored at `--cap-floor`, default 1 dps, never above `--cap`): the planner then runs continuously at about the commanded speed and arrives just before the next target instead of bursting. Recorded in the run params, on the Firmware-loop tab, re-armed from a saved run; documented. Co-Authored-By: Claude Fable 5.1 --- almond_axol/cli/tune/a4.py | 66 ++++++++++++++++++- docs/cli/tune-a4.mdx | 2 + tests/test_tune_a4.py | 19 ++++++ .../diagnostics/tuning-workbench.tsx | 20 ++++++ 4 files changed, 105 insertions(+), 2 deletions(-) diff --git a/almond_axol/cli/tune/a4.py b/almond_axol/cli/tune/a4.py index d52d7796..731cb3af 100644 --- a/almond_axol/cli/tune/a4.py +++ b/almond_axol/cli/tune/a4.py @@ -271,6 +271,32 @@ def _a4_frame(position_rad: float, cap_dps: float) -> bytes: ) +def speed_cap( + v_cmd_rad_s: float, cap_dps: float, track: float, floor_dps: float +) -> float: + """The 0xA4 speed cap (deg/s) for one streamed sample. + + ``track <= 0``: the fixed ``cap_dps``. Otherwise the cap follows the + commanded speed — ``track × |v_cmd|``, floored at ``floor_dps`` so a + stationary or reversing target can still be corrected, and never above + ``cap_dps``. + + Why: with the planner at its maximum (60000 dps/s) and a fixed 60 dps + cap, each 200 Hz target is a 0.015° step at 3 deg/s that the planner + covers in ~0.5 ms at the cap and then idles for the remaining 4.5 ms — + the joint moves in bursts at twenty times the commanded speed with a + 5 % duty cycle. On the right elbow that was 0.019° RMS tracking with + four times the current spread of direct tracking (1.28 A vs 0.33 A, + 0.76 A above 20 Hz, 68–82 Hz velocity content). A cap of ~1.1–1.2× the + commanded speed lets the planner run continuously at about that speed + and arrive just before the next target instead. + """ + if track <= 0.0: + return cap_dps + want = track * abs(math.degrees(v_cmd_rad_s)) + return min(cap_dps, max(floor_dps, want)) + + def _decode_a4_reply(resp: bytes) -> tuple[float, float]: """(iq A, speed rad/s) from a 0xA4 reply.""" iq = struct.unpack_from(" tuple[list[dict], str | None]: """Stream the wave; returns the log and the abort reason, if any.""" offset = motor.offset @@ -331,7 +359,8 @@ async def _stream( deadline = t0 for _t_nominal, target, v_cmd in samples: deadline += period - resp = await driver._request(_a4_frame(target - offset, cap_dps)) + cap = speed_cap(v_cmd, cap_dps, cap_track, cap_floor_dps) + resp = await driver._request(_a4_frame(target - offset, cap)) iq, speed = _decode_a4_reply(resp) fine = await driver._request(bytes([_MA_MULTI_TURN_ANGLE, 0, 0, 0, 0, 0, 0, 0])) pos = struct.unpack_from(" None: # type: ignore[ p.add_argument( "--cap", type=float, default=60.0, help="0xA4 speed cap, deg/s (default: 60)" ) + p.add_argument( + "--cap-track", + type=float, + default=0.0, + help="Make the per-command speed cap follow the wave: cap = this × |commanded " + "speed| (floored at --cap-floor, never above --cap). 0 (default) = fixed --cap. " + f"With --accel {_ACCEL_STEP_FOLLOW} a fixed cap lets the planner burst through " + "each 200 Hz step at the cap and idle the rest of the tick (4x the current " + "spread on the elbow); 1.1-1.2 keeps it moving continuously at about the " + "commanded speed.", + ) + p.add_argument( + "--cap-floor", + type=float, + default=1.0, + help="Lowest cap --cap-track may set, deg/s, so a stationary or reversing " + "target can still be corrected (default: 1)", + ) p.add_argument( "--accel", type=int, @@ -514,6 +561,11 @@ async def _run(args: argparse.Namespace) -> None: f" {args.mode} about {math.degrees(center):+.1f}° ±{args.amp:g}°, " + (f"{args.freq:g} Hz" if args.mode == "sine" else f"{args.speed:g} deg/s") + f", {args.duration:g} s at {args.rate:g} Hz, speed cap {args.cap:g} dps" + + ( + f" tracking {args.cap_track:g}× commanded speed (floor {args.cap_floor:g})" + if args.cap_track > 0 + else "" + ) ) channel = resolve_channel(args) @@ -602,7 +654,15 @@ async def _run(args: argparse.Namespace) -> None: live = LiveStream("sine", joint) print(" Running ...") log, reason = await _stream( - motor, driver, samples, args.cap, args.rate, guard, live + motor, + driver, + samples, + args.cap, + args.rate, + guard, + live, + cap_track=args.cap_track, + cap_floor_dps=args.cap_floor, ) live.flush() if reason is not None: @@ -691,6 +751,8 @@ async def _run(args: argparse.Namespace) -> None: "duration_s": args.duration, "rate_hz": args.rate, "cap_dps": args.cap, + "cap_track": args.cap_track, + "cap_floor_dps": args.cap_floor, "accel": list(accel_used) if accel_used else None, "persist": args.persist, } diff --git a/docs/cli/tune-a4.mdx b/docs/cli/tune-a4.mdx index 1358c866..620d5792 100644 --- a/docs/cli/tune-a4.mdx +++ b/docs/cli/tune-a4.mdx @@ -28,6 +28,8 @@ Safety, built in: | `--duration S` | Seconds of wave (default: 12) | | `--rate HZ` | Command rate (default: 200) | | `--cap DPS` | 0xA4 speed cap (default: 60) | +| `--cap-track K` | Per-command cap = `K × |commanded speed|`, floored at `--cap-floor`, never above `--cap` (default: 0 = fixed cap). With `--accel 60000` a fixed cap lets the planner burst through each 200 Hz step at the cap and idle the rest of the tick — 4× the current spread on the elbow, 68–82 Hz velocity content; `1.1`–`1.2` keeps the joint moving continuously at about the commanded speed | +| `--cap-floor DPS` | Lowest cap `--cap-track` may set, so a stationary or reversing target still corrects (default: 1) | | `--accel DPS/S` | Planner acceleration for the run, written to ROM before the mode-switch reset and restored afterwards unless `--keep`. `0` = direct PI tracking; `60000` = step-follow (see above); values in between will not follow a stream | | `--position-kp`, `--position-ki`, `--position-kd`, `--speed-kp`, `--speed-ki`, `--current-kp`, `--current-ki` | Firmware gains for the run (default: leave as is). The tool prints the motor's live values at start; the dashboard tab shows them next to each field and seeds its sliders there | | `--persist` | Write gains to ROM instead of RAM | diff --git a/tests/test_tune_a4.py b/tests/test_tune_a4.py index 834355e4..e163e5f6 100644 --- a/tests/test_tune_a4.py +++ b/tests/test_tune_a4.py @@ -136,3 +136,22 @@ def test_scores_lag_and_creep_smoothness(self) -> None: if __name__ == "__main__": unittest.main() + + +class SpeedCapTest(unittest.TestCase): + def test_zero_track_is_the_fixed_cap(self) -> None: + self.assertEqual(a4.speed_cap(math.radians(3.0), 60.0, 0.0, 1.0), 60.0) + self.assertEqual(a4.speed_cap(0.0, 60.0, 0.0, 1.0), 60.0) + + def test_tracking_cap_follows_commanded_speed_with_floor_and_ceiling(self) -> None: + # 3 deg/s commanded, 1.2× → 3.6 dps, sign-independent. + self.assertAlmostEqual(a4.speed_cap(math.radians(3.0), 60.0, 1.2, 1.0), 3.6) + self.assertAlmostEqual(a4.speed_cap(-math.radians(3.0), 60.0, 1.2, 1.0), 3.6) + # A stationary target keeps the floor so it can still be corrected … + self.assertEqual(a4.speed_cap(0.0, 60.0, 1.2, 1.0), 1.0) + # … and the fixed cap remains the ceiling. + self.assertEqual(a4.speed_cap(math.radians(100.0), 60.0, 1.2, 1.0), 60.0) + + def test_frame_carries_the_per_sample_cap(self) -> None: + frame = a4._a4_frame(0.0, a4.speed_cap(math.radians(3.0), 60.0, 1.2, 1.0)) + self.assertEqual(int.from_bytes(frame[2:4], "little"), 4) # 3.6 rounds to 4 dps diff --git a/web/app/src/components/diagnostics/tuning-workbench.tsx b/web/app/src/components/diagnostics/tuning-workbench.tsx index 71f55fc0..8ff9527e 100644 --- a/web/app/src/components/diagnostics/tuning-workbench.tsx +++ b/web/app/src/components/diagnostics/tuning-workbench.tsx @@ -369,6 +369,24 @@ const TABS: WbTab[] = [ { key: "duration", label: "duration (s)", type: "number", placeholder: "12" }, { key: "rate", label: "rate (Hz)", type: "number", placeholder: "200" }, { key: "cap", label: "speed cap (°/s)", type: "number", placeholder: "60" }, + { + key: "cap_track", + label: "cap tracks speed ×", + type: "number", + placeholder: "0", + hint: + "0 = fixed cap. With planner accel 60000 a fixed cap lets the planner burst " + + "through each 200 Hz step at the cap and idle the rest of the tick (4× the " + + "current spread on the elbow); 1.1–1.2 sets the per-command cap to that " + + "multiple of the commanded speed so the joint moves continuously", + }, + { + key: "cap_floor", + label: "cap floor (°/s)", + type: "number", + placeholder: "1", + hint: "lowest cap the tracking cap may set, so a stationary target still corrects", + }, { key: "accel", label: "planner accel (dps/s)", @@ -1132,6 +1150,8 @@ function runFormValues(meta: TuningRunMeta): Record | null { put("duration", p.duration_s) put("rate", p.rate_hz) put("cap", p.cap_dps) + if (typeof p.cap_track === "number" && p.cap_track > 0) put("cap_track", p.cap_track) + if (typeof p.cap_track === "number" && p.cap_track > 0) put("cap_floor", p.cap_floor_dps) if (Array.isArray(p.accel) && typeof p.accel[0] === "number") out["accel"] = String(p.accel[0]) for (const k of ["position_kp", "position_ki", "position_kd", "speed_kp", "speed_ki"]) { const v = g[k] From 4cdc57eb4e31679e086c4e9502864251512d4b35 Mon Sep 17 00:00:00 2001 From: Shawn Patel Date: Tue, 22 Sep 2026 04:06:26 +0000 Subject: [PATCH 22/80] tune.a4: ignore --cap-track under direct tracking (planner 0) Under direct PI tracking the 0xA4 cap is a hard limit on the loop output; pinned at 1.1x the commanded speed the loop can never catch up (right elbow, pKp 0.5: 1.8 deg RMS, 480 ms lag, re-armed from a planner run on the dashboard). The knob is for the planner's per-tick bursts, which direct tracking does not have, so it is dropped with a warning when accel is 0. Co-Authored-By: Claude Fable 5.1 --- almond_axol/cli/tune/a4.py | 20 +++++++++++++++++--- docs/cli/tune-a4.mdx | 2 +- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/almond_axol/cli/tune/a4.py b/almond_axol/cli/tune/a4.py index 731cb3af..cc5b0237 100644 --- a/almond_axol/cli/tune/a4.py +++ b/almond_axol/cli/tune/a4.py @@ -562,7 +562,8 @@ async def _run(args: argparse.Namespace) -> None: + (f"{args.freq:g} Hz" if args.mode == "sine" else f"{args.speed:g} deg/s") + f", {args.duration:g} s at {args.rate:g} Hz, speed cap {args.cap:g} dps" + ( - f" tracking {args.cap_track:g}× commanded speed (floor {args.cap_floor:g})" + f" tracking {args.cap_track:g}× commanded speed (floor {args.cap_floor:g}" + ", planner permitting)" if args.cap_track > 0 else "" ) @@ -611,6 +612,19 @@ async def _run(args: argparse.Namespace) -> None: "each step within the tick)" ) + cap_track = args.cap_track + if accel_used[0] == 0 and cap_track > 0: + # Under direct tracking the cap is a hard limit on the PI output: + # pinned near the commanded speed the loop can never catch up + # (right elbow, pKp 0.5, cap-track 1.1: 1.8° RMS, 480 ms lag). + # The knob exists for the planner's per-tick bursts, which + # direct tracking does not have. + print( + f" ! --cap-track {cap_track:g} ignored: the planner is at 0 (direct " + "PI tracking), where the cap would only throttle the loop" + ) + cap_track = 0.0 + motors = await joint_frame_motors(raw, is_left) await asyncio.gather( *[ @@ -661,7 +675,7 @@ async def _run(args: argparse.Namespace) -> None: args.rate, guard, live, - cap_track=args.cap_track, + cap_track=cap_track, cap_floor_dps=args.cap_floor, ) live.flush() @@ -751,7 +765,7 @@ async def _run(args: argparse.Namespace) -> None: "duration_s": args.duration, "rate_hz": args.rate, "cap_dps": args.cap, - "cap_track": args.cap_track, + "cap_track": cap_track, "cap_floor_dps": args.cap_floor, "accel": list(accel_used) if accel_used else None, "persist": args.persist, diff --git a/docs/cli/tune-a4.mdx b/docs/cli/tune-a4.mdx index 620d5792..192c5323 100644 --- a/docs/cli/tune-a4.mdx +++ b/docs/cli/tune-a4.mdx @@ -28,7 +28,7 @@ Safety, built in: | `--duration S` | Seconds of wave (default: 12) | | `--rate HZ` | Command rate (default: 200) | | `--cap DPS` | 0xA4 speed cap (default: 60) | -| `--cap-track K` | Per-command cap = `K × |commanded speed|`, floored at `--cap-floor`, never above `--cap` (default: 0 = fixed cap). With `--accel 60000` a fixed cap lets the planner burst through each 200 Hz step at the cap and idle the rest of the tick — 4× the current spread on the elbow, 68–82 Hz velocity content; `1.1`–`1.2` keeps the joint moving continuously at about the commanded speed | +| `--cap-track K` | Per-command cap = `K × |commanded speed|`, floored at `--cap-floor`, never above `--cap` (default: 0 = fixed cap). With `--accel 60000` a fixed cap lets the planner burst through each 200 Hz step at the cap and idle the rest of the tick — 4× the current spread on the elbow, 68–82 Hz velocity content; `1.1`–`1.2` keeps the joint moving continuously at about the commanded speed. Ignored (with a warning) when the planner is at 0: under direct tracking the cap is a hard limit on the PI output and would only throttle the loop | | `--cap-floor DPS` | Lowest cap `--cap-track` may set, so a stationary or reversing target still corrects (default: 1) | | `--accel DPS/S` | Planner acceleration for the run, written to ROM before the mode-switch reset and restored afterwards unless `--keep`. `0` = direct PI tracking; `60000` = step-follow (see above); values in between will not follow a stream | | `--position-kp`, `--position-ki`, `--position-kd`, `--speed-kp`, `--speed-ki`, `--current-kp`, `--current-ki` | Firmware gains for the run (default: leave as is). The tool prints the motor's live values at start; the dashboard tab shows them next to each field and seeds its sliders there | From c26dd3d60b73ad353f6bf7571e91bc62ef5526fa Mon Sep 17 00:00:00 2001 From: Shawn Patel Date: Tue, 22 Sep 2026 04:08:42 +0000 Subject: [PATCH 23/80] Elbow firmware position_kp 0.2 -> 0.5 Direct-tracking sweep on right elbow (2026-09-21, 3 deg/s triangle at -75, planner 0): 0.2 / 0.3 / 0.5 gave 0.17 / 0.11 / 0.07 deg RMS and 54 / 37 / 23 ms lag with velocity ripple, stuck fraction and buzz at the floor throughout and >20 Hz current 0.05 -> 0.12 A. The shoulder began to buzz past 0.5, so 0.5 is the knee. Written to the elbow's ROM (and the motor reset) at the next enable. Co-Authored-By: Claude Fable 5.1 --- almond_axol/robot/config.py | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/almond_axol/robot/config.py b/almond_axol/robot/config.py index b3c2f386..82a190a9 100644 --- a/almond_axol/robot/config.py +++ b/almond_axol/robot/config.py @@ -371,12 +371,15 @@ class PositionForceConfig: ) # The X6-P20 elbow's set (its stock position_kp is 0.15, speed_kp 0.01 on -# firmware 2025070202): position_kp 0.2 with the shoulders' speed loop. -# Chosen on 2026-09-21 after the elbow first tracked under 0xA4 at stock -# gains — the earlier "elbow does not move" runs were the planner write, -# not the loop (see ``tune.a4``). Same ROM write at enable, same a4-only. +# firmware 2025070202): position_kp 0.5 with the shoulders' speed loop, from +# the 2026-09-21 direct-tracking sweep on right elbow (3 deg/s triangle at +# -75°, planner 0): 0.2 → 0.3 → 0.5 took tracking 0.17° → 0.11° → 0.07° +# RMS and lag 54 → 37 → 23 ms with every smoothness figure at the floor +# (velocity ripple 0.13, buzz 0.005°, current spread 0.31 A) and >20 Hz +# current only 0.05 → 0.12 A; the shoulder's buzz began past 0.5, so this +# is the knee, not a ceiling. Same ROM write at enable, same a4-only. _X6_ELBOW_FIRMWARE_GAINS = FirmwareGains( - position_kp=0.2, + position_kp=0.5, position_kd=0.1, speed_kp=0.1, speed_ki=1e-5, From 1416248b77f0a4878ee1192e89747d8cb39861e3 Mon Sep 17 00:00:00 2001 From: Shawn Patel Date: Tue, 22 Sep 2026 04:10:21 +0000 Subject: [PATCH 24/80] Elbow firmware position_kp 0.5: test and docs follow-up The previous commit carried only the config change; its test and docs edits did not apply (the test file had been reformatted under the match string and the script stopped before the docs step) while the commit went through with two failing tests. This makes the branch green again. Co-Authored-By: Claude Fable 5.1 --- docs/snippets/config/robot.mdx | 2 +- tests/test_firmware_gains.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/snippets/config/robot.mdx b/docs/snippets/config/robot.mdx index 2d61cb7d..a5bf27fc 100644 --- a/docs/snippets/config/robot.mdx +++ b/docs/snippets/config/robot.mdx @@ -32,7 +32,7 @@ export const F = ({ children }) => {child | --{prefix}left.elbow.friction.fv | per motor | Viscous friction (Nm·s/rad). | | --{prefix}left.elbow.friction.fo | per motor | Constant torque offset (Nm). | | --{prefix}left.elbow.friction.fl | `0.0` | Load-proportional Coulomb friction, Nm per Nm of gravity feed-forward: the effective `fc` is `fc + fl × |gravity|`. Planetary gear friction grows with the torque it carries (right shoulder_1: ~0.6 Nm sliding at rest, ~1.5 Nm under 12 Nm), so a constant `fc` over-compensates at rest and under-compensates at reach. `0` keeps the constant model. | -| --{prefix}left.elbow.firmware.position_kp | `null` (shoulders `0.3`, elbow `0.2`) | **MyActuator firmware** position-loop proportional gain, written to the motor's ROM at enable when set (`null` leaves the motor's stored value). These five gains are the motor's own position PI → speed PI controller and only act under `wire_mode` `a4`; identify them with [`tune.a4`](/cli/tune-a4). Stock 0.008 on the X8-P20 shoulders stick-slips at creep speed; 0.3 is the knee where the stairs are gone and the loop has not started to buzz. | +| --{prefix}left.elbow.firmware.position_kp | `null` (shoulders `0.3`, elbow `0.5`) | **MyActuator firmware** position-loop proportional gain, written to the motor's ROM at enable when set (`null` leaves the motor's stored value). These five gains are the motor's own position PI → speed PI controller and only act under `wire_mode` `a4`; identify them with [`tune.a4`](/cli/tune-a4). Stock 0.008 on the X8-P20 shoulders stick-slips at creep speed; 0.3 is the knee where the stairs are gone and the loop has not started to buzz. | | --{prefix}left.elbow.firmware.position_ki | `null` | Firmware position-loop integral gain. Leave at the stock `0`: on top of the speed integrator it hunts around the target. | | --{prefix}left.elbow.firmware.position_kd | `null` (shoulders and elbow `0.1`) | Firmware position-loop derivative gain (protocol V4.2+). The motor stores it, but the 0xA4 loop on the X8-P20 measured inert to it. | | --{prefix}left.elbow.firmware.speed_kp | `null` (shoulders and elbow `0.1`) | Firmware speed-loop proportional gain: the 0xA4 loop's only damping term, and its buzz knob (0.15 doubled the >20 Hz current on shoulder_1). | diff --git a/tests/test_firmware_gains.py b/tests/test_firmware_gains.py index 4c8ec648..29085e77 100644 --- a/tests/test_firmware_gains.py +++ b/tests/test_firmware_gains.py @@ -33,7 +33,7 @@ def test_elbow_carries_its_own_set_on_both_arms(self) -> None: self.assertEqual( arm.elbow.firmware.as_dict(), { - "position_kp": 0.2, + "position_kp": 0.5, "position_kd": 0.1, "speed_kp": 0.1, "speed_ki": 1e-5, @@ -189,7 +189,7 @@ async def test_cold_configured_joints_get_their_gains_and_others_are_untouched( ) for motor in (s1, s2): self.assertAlmostEqual(motor.store[_MA_PID_IDX["position_kp"]], 0.3, 6) - self.assertAlmostEqual(elbow.store[_MA_PID_IDX["position_kp"]], 0.2, 6) + self.assertAlmostEqual(elbow.store[_MA_PID_IDX["position_kp"]], 0.5, 6) for motor in (s1, s2, elbow): self.assertAlmostEqual(motor.store[_MA_PID_IDX["speed_kp"]], 0.1, 6) self.assertAlmostEqual(motor.store[_MA_PID_IDX["speed_ki"]], 1e-5, 9) From af7fddea036eec2a91583068a95d57ff68abc198 Mon Sep 17 00:00:00 2001 From: Shawn Patel Date: Tue, 22 Sep 2026 04:26:43 +0000 Subject: [PATCH 25/80] tune.a4: score the current spread and the 3-8 Hz mode current What the operator feels at speed on the X8-P20 shoulder is the position loop's own ~5 Hz mode in the current: a 12 deg/s triangle's reversals kick it to 1.9 A at position_kp 0.7 against 0.2 A at 3 deg/s, and neither the >10 Hz position buzz nor the >20 Hz current band move with it (the run that was quiet by ear had the highest >20 Hz current of the set). iq_mode (3-8 Hz band of the current, gravity hold removed) and iq_sd (its spread) are now on the scorecard and the dashboard's Firmware-loop card, with the caveat that anything above 100 Hz is invisible to the 200 Hz stream. Co-Authored-By: Claude Fable 5.1 --- almond_axol/cli/tune/a4.py | 19 ++++++++++- tests/test_tune_a4.py | 33 +++++++++++++++++++ .../diagnostics/tuning-workbench.tsx | 9 +++-- 3 files changed, 58 insertions(+), 3 deletions(-) diff --git a/almond_axol/cli/tune/a4.py b/almond_axol/cli/tune/a4.py index cc5b0237..c542d4ea 100644 --- a/almond_axol/cli/tune/a4.py +++ b/almond_axol/cli/tune/a4.py @@ -254,6 +254,21 @@ def a4_metrics(log: list[dict], rate: float) -> dict[str, Any]: m["buzz"] = float(math.sqrt(F[f >= 10].sum() / tot) * e.std()) if tot > 0 else 0.0 m["iq_rms"] = float(np.sqrt(np.nanmean(iq * iq))) m["iq_max"] = float(np.nanmax(np.abs(iq))) + # Current *variation* — what the operator feels. The mean current is the + # gravity hold and says nothing about smoothness; its spread does, and + # the 3-8 Hz band of it is the position loop's own mode (~5 Hz on the + # X8-P20 shoulders), the shudder a 12 deg/s triangle's reversals kick up + # (1.9 A at position_kp 0.7 against 0.2 A at 3 deg/s) that neither the + # >10 Hz position "buzz" nor the >20 Hz current band track. + iq_c = iq - np.nanmean(iq) + m["iq_sd"] = float(np.nanstd(iq)) + Fi = np.abs(np.fft.rfft(np.nan_to_num(iq_c) * np.hanning(n))) ** 2 + tot_i = float(Fi.sum()) + m["iq_mode"] = ( + float(math.sqrt(Fi[(f >= 3) & (f <= 8)].sum() / tot_i) * m["iq_sd"]) + if tot_i > 0 + else 0.0 + ) return m @@ -751,7 +766,9 @@ async def _run(args: argparse.Namespace) -> None: f" velocity ripple {metrics['v_ripple']:.2f} (MIT stick-slip ≈ 0.8, smooth < 0.2) stuck windows {metrics['stuck_frac']:.2f}" ) print( - f" current RMS {metrics['iq_rms']:.2f} A peak {metrics['iq_max']:.2f} A loop {metrics['hz']:.0f} Hz" + f" current RMS {metrics['iq_rms']:.2f} A peak {metrics['iq_max']:.2f} A " + f"spread {metrics['iq_sd']:.2f} A 3-8 Hz mode {metrics['iq_mode']:.2f} A " + f"loop {metrics['hz']:.0f} Hz" ) print(f"{'─' * 66}") if args.save_run: diff --git a/tests/test_tune_a4.py b/tests/test_tune_a4.py index e163e5f6..3b52ea02 100644 --- a/tests/test_tune_a4.py +++ b/tests/test_tune_a4.py @@ -128,6 +128,9 @@ def test_scores_lag_and_creep_smoothness(self) -> None: self.assertEqual(smooth["stuck_frac"], 0.0) self.assertLess(math.degrees(smooth["band_1_4"]), 0.01) self.assertAlmostEqual(smooth["iq_rms"], 1.5) + # A constant current is a gravity hold, not vibration: no spread, no mode. + self.assertAlmostEqual(smooth["iq_sd"], 0.0) + self.assertAlmostEqual(smooth["iq_mode"], 0.0) # A 2 Hz ±0.3° wobble on the same creep shows up in the band and ripple. wobbly = a4.a4_metrics(self._log(0.1, math.radians(0.3)), 200.0) self.assertGreater(math.degrees(wobbly["band_1_4"]), 0.15) @@ -155,3 +158,33 @@ def test_tracking_cap_follows_commanded_speed_with_floor_and_ceiling(self) -> No def test_frame_carries_the_per_sample_cap(self) -> None: frame = a4._a4_frame(0.0, a4.speed_cap(math.radians(3.0), 60.0, 1.2, 1.0)) self.assertEqual(int.from_bytes(frame[2:4], "little"), 4) # 3.6 rounds to 4 dps + + +class CurrentModeMetricTest(unittest.TestCase): + def test_mode_current_isolates_the_3_to_8_hz_shudder(self) -> None: + rate = 200.0 + n = 2400 + t = np.arange(n) / rate + # 5 Hz, 1 A amplitude on a -8 A gravity hold, plus a 60 Hz 0.3 A buzz. + iq = ( + -8.0 + + 1.0 * np.sin(2 * math.pi * 5.0 * t) + + 0.3 * np.sin(2 * math.pi * 60.0 * t) + ) + log = [ + { + "t": ti, + "target": 0.0, + "actual": 0.0, + "error": 0.0, + "v_cmd": 0.1, + "iq": qi, + } + for ti, qi in zip(t, iq) + ] + m = a4.a4_metrics(log, rate) + # sd of the two sines: sqrt(0.5 + 0.045) ≈ 0.738 A + self.assertAlmostEqual(m["iq_sd"], math.sqrt(0.5 + 0.045), places=2) + # The 3-8 Hz share is the 5 Hz tone's std alone: 1/sqrt(2) ≈ 0.707 A. + self.assertAlmostEqual(m["iq_mode"], 1.0 / math.sqrt(2), places=1) + self.assertLess(m["iq_mode"], m["iq_sd"]) diff --git a/web/app/src/components/diagnostics/tuning-workbench.tsx b/web/app/src/components/diagnostics/tuning-workbench.tsx index 8ff9527e..fdca5bed 100644 --- a/web/app/src/components/diagnostics/tuning-workbench.tsx +++ b/web/app/src/components/diagnostics/tuning-workbench.tsx @@ -1852,6 +1852,8 @@ const A4_COLS: ScoreCol[] = [ { key: "stuck_frac", label: "stuck", digits: 2, warn: 0.05, bad: 0.3 }, { key: "band_1_4", label: "1–4 Hz °", deg: true, digits: 3, warn: 0.1, bad: 0.3 }, { key: "buzz", label: ">10 Hz buzz °", deg: true, digits: 3, warn: 0.02, bad: 0.1 }, + { key: "iq_mode", label: "3–8 Hz mode A", digits: 2, warn: 0.5, bad: 1.0 }, + { key: "iq_sd", label: "current spread A", digits: 2, warn: 1.3, bad: 1.8 }, { key: "iq_rms", label: "current RMS A", digits: 2 }, { key: "iq_max", label: "peak A", digits: 1 }, { key: "hz", label: "loop Hz", digits: 0 }, @@ -1906,8 +1908,11 @@ const A4_LEGEND = "followed. vel ripple = std of measured minus commanded velocity over the " + "commanded speed — the MIT frame's stick-slip sits near 0.8, smooth is under 0.2. " + "stuck = fraction of the pass with the joint not moving. 1–4 Hz = the stick-slip " + - "band in the error; >10 Hz buzz = high-frequency position motion (the current " + - "columns show a speed-loop buzz the 0.01° position read cannot)." + "band in the error; >10 Hz buzz = high-frequency position motion. 3–8 Hz mode = " + + "the position loop's own mode in the current — the shudder felt at speed (a 12 deg/s " + + "triangle's reversals kick it to ~1.9 A at position_kp 0.7; 0.2 A is quiet); current " + + "spread = all current variation, the gravity hold removed. Anything above 100 Hz is " + + "invisible to the 200 Hz stream, so an audible buzz can leave every column clean." const SCORE_LEGEND: Record = { motion: From 961e6682548107a3455c033261e1ebd5459469b9 Mon Sep 17 00:00:00 2001 From: Shawn Patel Date: Tue, 22 Sep 2026 05:14:40 +0000 Subject: [PATCH 26/80] Firmware loop gains tuned for the 400 Hz a4 stream: shoulders 1.0/0.07, elbow 1.4/0.05 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The core is moving its a4 stream to 400 Hz (200 Hz put an audible target staircase on the shoulder at 12 deg/s that 400 removed), so both joints were re-swept at 400 Hz on 2026-09-21 — 12 deg/s triangles and 40 deg/s sines, right arm, three poses each, through the dashboard launcher. Two findings drive the values. Sampling at 400 Hz shows what the >20 Hz current band had been aliasing: the speed loop's own resonance, ~100 Hz on the X8-P20 shoulder and ~135 Hz on the X6-P20 elbow, whose current tone grows with speed_kp until the loop goes unstable (shoulder: 0.07 → 0.30 → 0.41 → 1.0 A at 0.1 / 0.13 / 0.16 / 0.2, 32 A abort at 0.2; elbow: 0.04 → 0.21 A from position_kp 0.5 → 1.0 at speed_kp 0.1, 34 A abort at 1.5). speed_kp is not a damper here — it did nothing for the 5 Hz reversal mode (2.2 A at 0.13 and 0.16 alike) — so it goes *down*: 0.07 on the shoulder, 0.05 on the elbow put the tone at the stock floor. And at 400 Hz the >10 Hz position buzz no longer moves with stiffness, so position_kp climbs: shoulder 0.3 → 1.0 takes 12 deg/s tracking from 0.44° / 36 ms to 0.17° / 12 ms (1.4 also clean, so 1.0 carries margin; held at -10° and -70° and on the 40 deg/s sine, ripple 0.08); elbow 0.2 → 1.4 takes it from 0.59° / 48 ms to 0.098° / 8 ms with velocity ripple 0.13 and current spread 0.41 A (1.8 still clean; -30° and -120° and the sine, ripple 0.05, all match). tune.a4 now defaults to --rate 400 (tool, dashboard placeholder, docs): tune at the rate you run, the step size is the excitation. Co-Authored-By: Claude Fable 5.1 --- almond_axol/cli/tune/a4.py | 5 +- almond_axol/robot/config.py | 46 ++++++++++--------- docs/cli/tune-a4.mdx | 2 +- docs/snippets/config/robot.mdx | 4 +- tests/test_firmware_gains.py | 16 ++++--- .../diagnostics/tuning-workbench.tsx | 2 +- 6 files changed, 41 insertions(+), 34 deletions(-) diff --git a/almond_axol/cli/tune/a4.py b/almond_axol/cli/tune/a4.py index c542d4ea..cf0d056d 100644 --- a/almond_axol/cli/tune/a4.py +++ b/almond_axol/cli/tune/a4.py @@ -464,7 +464,10 @@ def add_parser(subparsers: argparse._SubParsersAction) -> None: # type: ignore[ "--duration", type=float, default=12.0, help="Seconds of wave (default: 12)" ) p.add_argument( - "--rate", type=float, default=200.0, help="Command rate, Hz (default: 200)" + "--rate", + type=float, + default=400.0, + help="Command rate, Hz (default: 400 — the realtime core's a4 stream rate; 200 Hz put an audible target staircase on the X8-P20 shoulder at 12 deg/s that 400 removed)", ) p.add_argument( "--cap", type=float, default=60.0, help="0xA4 speed cap, deg/s (default: 60)" diff --git a/almond_axol/robot/config.py b/almond_axol/robot/config.py index 82a190a9..78885bf3 100644 --- a/almond_axol/robot/config.py +++ b/almond_axol/robot/config.py @@ -353,35 +353,37 @@ class PositionForceConfig: # Firmware loop gains for the X8-P20 shoulders (shoulder_1 / shoulder_2), -# from the ``tune.a4`` sweeps on right shoulder_1 (2026-09-18/21, 3 and -# 12 deg/s triangles at -45°): position_kp 0.3 is the knee where the creep -# stairs are gone (velocity ripple 0.26 vs 0.86 stock, zero stuck windows) -# and the >20 Hz current is still at the stock floor; 0.5 starts a ~5 Hz -# loop mode, 1.0 buzzes. speed_kp 0.1 (3x stock) is the damping — 0.15 -# doubled the buzz. speed_ki is *lowered* from the stock 1e-4: the -# integrator winds up while the joint is stuck and dumps it at release. -# position_kd is the stock value; the firmware stores it but the 0xA4 loop -# measured inert to it. Written to ROM once at enable (see -# :class:`FirmwareGains`); they only act under ``wire_mode`` ``a4``. +# from the 2026-09-21 ``tune.a4`` sweeps on right shoulder_1 — the 400 Hz +# stream the realtime core is moving to, 12 deg/s triangle and 40 deg/s +# sine at -45°, checked at -10° and -70°. Stiffness: the stick-slip stairs +# are gone by position_kp 0.2 and tracking keeps improving (0.44° / 36 ms +# at 0.3 → 0.17° / 12 ms at 1.0 → 0.13° / 9 ms at 1.4); at 400 Hz the +# >10 Hz position buzz no longer moves with it. Damping: speed_kp is the +# speed loop's own ~100 Hz resonance knob, not a damper — it did nothing +# for the 5 Hz reversal mode (2.2 A at 0.13 and 0.16 alike) while the +# 100 Hz current tone went 0.07 → 0.30 → 0.41 → 1.0 A (unstable, 32 A +# abort) at 0.1 / 0.13 / 0.16 / 0.2. Lowering it to 0.07 puts the tone at +# the stock floor (0.04 A) with the tracking intact; 1.4 / 0.07 was also +# clean, so 1.0 / 0.07 carries margin. speed_ki 1e-5: 1e-6 was identical. _X8_FIRMWARE_GAINS = FirmwareGains( - position_kp=0.3, + position_kp=1.0, position_kd=0.1, - speed_kp=0.1, + speed_kp=0.07, speed_ki=1e-5, ) -# The X6-P20 elbow's set (its stock position_kp is 0.15, speed_kp 0.01 on -# firmware 2025070202): position_kp 0.5 with the shoulders' speed loop, from -# the 2026-09-21 direct-tracking sweep on right elbow (3 deg/s triangle at -# -75°, planner 0): 0.2 → 0.3 → 0.5 took tracking 0.17° → 0.11° → 0.07° -# RMS and lag 54 → 37 → 23 ms with every smoothness figure at the floor -# (velocity ripple 0.13, buzz 0.005°, current spread 0.31 A) and >20 Hz -# current only 0.05 → 0.12 A; the shoulder's buzz began past 0.5, so this -# is the knee, not a ceiling. Same ROM write at enable, same a4-only. +# The X6-P20 elbow's set (stock position_kp 0.15, speed_kp 0.01 on firmware +# 2025070202), from the same 2026-09-21 400 Hz sweeps on right elbow (12 deg/s +# triangle and 40 deg/s sine at -75°, checked at -30° and -120°). Same law +# as the shoulders with the speed loop's resonance at ~135 Hz: at speed_kp +# 0.1 it grew 0.04 → 0.21 A from position_kp 0.5 → 1.0 and went unstable at +# 1.5 (34 A abort); at 0.05 it stays at 0.05-0.08 A through 1.4 and even +# 1.8. 1.4 / 0.05: 0.098° RMS, 8 ms lag, velocity ripple 0.13, current +# spread 0.41 A — 1.8 was still clean, so this carries margin. _X6_ELBOW_FIRMWARE_GAINS = FirmwareGains( - position_kp=0.5, + position_kp=1.4, position_kd=0.1, - speed_kp=0.1, + speed_kp=0.05, speed_ki=1e-5, ) diff --git a/docs/cli/tune-a4.mdx b/docs/cli/tune-a4.mdx index 192c5323..92e64848 100644 --- a/docs/cli/tune-a4.mdx +++ b/docs/cli/tune-a4.mdx @@ -26,7 +26,7 @@ Safety, built in: | `--freq HZ` | Sine frequency (default: 0.3) | | `--speed DPS` | Triangle pass speed, deg/s (default: 3) | | `--duration S` | Seconds of wave (default: 12) | -| `--rate HZ` | Command rate (default: 200) | +| `--rate HZ` | Command rate (default: 400, the rate the realtime core streams a4 joints at; tune at the rate you will run — the step size is the excitation, and 200 Hz put an audible staircase on the shoulder at 12 deg/s that 400 removed) | | `--cap DPS` | 0xA4 speed cap (default: 60) | | `--cap-track K` | Per-command cap = `K × |commanded speed|`, floored at `--cap-floor`, never above `--cap` (default: 0 = fixed cap). With `--accel 60000` a fixed cap lets the planner burst through each 200 Hz step at the cap and idle the rest of the tick — 4× the current spread on the elbow, 68–82 Hz velocity content; `1.1`–`1.2` keeps the joint moving continuously at about the commanded speed. Ignored (with a warning) when the planner is at 0: under direct tracking the cap is a hard limit on the PI output and would only throttle the loop | | `--cap-floor DPS` | Lowest cap `--cap-track` may set, so a stationary or reversing target still corrects (default: 1) | diff --git a/docs/snippets/config/robot.mdx b/docs/snippets/config/robot.mdx index a5bf27fc..3d63d5e1 100644 --- a/docs/snippets/config/robot.mdx +++ b/docs/snippets/config/robot.mdx @@ -32,10 +32,10 @@ export const F = ({ children }) => {child | --{prefix}left.elbow.friction.fv | per motor | Viscous friction (Nm·s/rad). | | --{prefix}left.elbow.friction.fo | per motor | Constant torque offset (Nm). | | --{prefix}left.elbow.friction.fl | `0.0` | Load-proportional Coulomb friction, Nm per Nm of gravity feed-forward: the effective `fc` is `fc + fl × |gravity|`. Planetary gear friction grows with the torque it carries (right shoulder_1: ~0.6 Nm sliding at rest, ~1.5 Nm under 12 Nm), so a constant `fc` over-compensates at rest and under-compensates at reach. `0` keeps the constant model. | -| --{prefix}left.elbow.firmware.position_kp | `null` (shoulders `0.3`, elbow `0.5`) | **MyActuator firmware** position-loop proportional gain, written to the motor's ROM at enable when set (`null` leaves the motor's stored value). These five gains are the motor's own position PI → speed PI controller and only act under `wire_mode` `a4`; identify them with [`tune.a4`](/cli/tune-a4). Stock 0.008 on the X8-P20 shoulders stick-slips at creep speed; 0.3 is the knee where the stairs are gone and the loop has not started to buzz. | +| --{prefix}left.elbow.firmware.position_kp | `null` (shoulders `1.0`, elbow `1.4`) | **MyActuator firmware** position-loop proportional gain, written to the motor's ROM at enable when set (`null` leaves the motor's stored value). These five gains are the motor's own position PI → speed PI controller and only act under `wire_mode` `a4`; identify them with [`tune.a4`](/cli/tune-a4). Stock 0.008 on the X8-P20 shoulders stick-slips at creep speed; the stairs are gone by 0.2 and, with the 400 Hz stream the core runs, tracking keeps improving to 1.0 (0.17° / 12 ms at 12 deg/s) with 1.4 still clean. | | --{prefix}left.elbow.firmware.position_ki | `null` | Firmware position-loop integral gain. Leave at the stock `0`: on top of the speed integrator it hunts around the target. | | --{prefix}left.elbow.firmware.position_kd | `null` (shoulders and elbow `0.1`) | Firmware position-loop derivative gain (protocol V4.2+). The motor stores it, but the 0xA4 loop on the X8-P20 measured inert to it. | -| --{prefix}left.elbow.firmware.speed_kp | `null` (shoulders and elbow `0.1`) | Firmware speed-loop proportional gain: the 0xA4 loop's only damping term, and its buzz knob (0.15 doubled the >20 Hz current on shoulder_1). | +| --{prefix}left.elbow.firmware.speed_kp | `null` (shoulders `0.07`, elbow `0.05`) | Firmware speed-loop proportional gain. Not a damper in practice — it did nothing for the loop's 5 Hz reversal mode — but the knob on the speed loop's own resonance (~100 Hz on the X8-P20, ~135 Hz on the X6-P20): the current tone grows with it until the loop goes unstable (0.2 on the shoulder, 0.1 at position_kp 1.5 on the elbow). Lower is quieter at no tracking cost. | | --{prefix}left.elbow.firmware.speed_ki | `null` (shoulders and elbow `1e-05`) | Firmware speed-loop integral gain. Lower than the stock `1e-4` is smoother on a geared joint: the integrator winds up while the joint is stuck and dumps it at release. | Per-arm and per-joint flags are shown for `left` / `elbow`. Swap in `right`, or any joint: `shoulder_1`, `shoulder_2`, `shoulder_3`, `elbow`, `wrist_1`, `wrist_2`, `wrist_3`. diff --git a/tests/test_firmware_gains.py b/tests/test_firmware_gains.py index 29085e77..33659602 100644 --- a/tests/test_firmware_gains.py +++ b/tests/test_firmware_gains.py @@ -16,7 +16,7 @@ from almond_axol.robot.axol import apply_firmware_gains from almond_axol.robot.config import AxolConfig, _calibrated_joint -_X8 = {"position_kp": 0.3, "position_kd": 0.1, "speed_kp": 0.1, "speed_ki": 1e-5} +_X8 = {"position_kp": 1.0, "position_kd": 0.1, "speed_kp": 0.07, "speed_ki": 1e-5} class ConfigTest(unittest.TestCase): @@ -33,9 +33,9 @@ def test_elbow_carries_its_own_set_on_both_arms(self) -> None: self.assertEqual( arm.elbow.firmware.as_dict(), { - "position_kp": 0.5, + "position_kp": 1.4, "position_kd": 0.1, - "speed_kp": 0.1, + "speed_kp": 0.05, "speed_ki": 1e-5, }, ) @@ -124,7 +124,7 @@ async def test_writes_only_the_gains_that_differ(self) -> None: [_MA_PID_IDX[n] for n in ("position_kp", "speed_kp", "speed_ki")], ) self.assertAlmostEqual(changed["position_kp"][0], 0.008) - self.assertAlmostEqual(changed["position_kp"][1], 0.3, places=6) + self.assertAlmostEqual(changed["position_kp"][1], 1.0, places=6) self.assertAlmostEqual(motor.store[_MA_PID_IDX["speed_ki"]], 1e-5, places=9) async def test_second_call_is_read_only(self) -> None: @@ -188,10 +188,12 @@ async def test_cold_configured_joints_get_their_gains_and_others_are_untouched( arm, [Joint.SHOULDER_1, Joint.SHOULDER_2, Joint.ELBOW, Joint.SHOULDER_3] ) for motor in (s1, s2): - self.assertAlmostEqual(motor.store[_MA_PID_IDX["position_kp"]], 0.3, 6) - self.assertAlmostEqual(elbow.store[_MA_PID_IDX["position_kp"]], 0.5, 6) + self.assertAlmostEqual(motor.store[_MA_PID_IDX["position_kp"]], 1.0, 6) + self.assertAlmostEqual(elbow.store[_MA_PID_IDX["position_kp"]], 1.4, 6) + for motor in (s1, s2): + self.assertAlmostEqual(motor.store[_MA_PID_IDX["speed_kp"]], 0.07, 6) + self.assertAlmostEqual(elbow.store[_MA_PID_IDX["speed_kp"]], 0.05, 6) for motor in (s1, s2, elbow): - self.assertAlmostEqual(motor.store[_MA_PID_IDX["speed_kp"]], 0.1, 6) self.assertAlmostEqual(motor.store[_MA_PID_IDX["speed_ki"]], 1e-5, 9) # position_kd 0.1 is already the stock value: read, never written. self.assertNotIn(_MA_PID_IDX["position_kd"], [i for i, _ in motor.writes]) diff --git a/web/app/src/components/diagnostics/tuning-workbench.tsx b/web/app/src/components/diagnostics/tuning-workbench.tsx index fdca5bed..e6ece49d 100644 --- a/web/app/src/components/diagnostics/tuning-workbench.tsx +++ b/web/app/src/components/diagnostics/tuning-workbench.tsx @@ -367,7 +367,7 @@ const TABS: WbTab[] = [ { key: "speed", label: "triangle speed (°/s)", type: "number", placeholder: "3" }, { key: "freq", label: "sine freq (Hz)", type: "number", placeholder: "0.3" }, { key: "duration", label: "duration (s)", type: "number", placeholder: "12" }, - { key: "rate", label: "rate (Hz)", type: "number", placeholder: "200" }, + { key: "rate", label: "rate (Hz)", type: "number", placeholder: "400" }, { key: "cap", label: "speed cap (°/s)", type: "number", placeholder: "60" }, { key: "cap_track", From 518207b035bafd4e3b0b034853dbd5787830ec10 Mon Sep 17 00:00:00 2001 From: Shawn Patel Date: Tue, 22 Sep 2026 15:34:41 +0000 Subject: [PATCH 27/80] axol-rt: report per-tick bus occupancy in the 5 s stats line MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tick start to last reply, as a fraction of the period: p50 / p95 / max over each 5 s window. This is what bounds the loop rate — at 240 Hz with three a4 joints the right arm's bus is estimated near three quarters of 1 Mbps — and the move to 400 Hz has to be sized against a measurement, not the estimate. Printed by every core session (tune.motion, teleop). Co-Authored-By: Claude Fable 5.1 --- rust/axol-rt/src/serve.rs | 41 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 40 insertions(+), 1 deletion(-) diff --git a/rust/axol-rt/src/serve.rs b/rust/axol-rt/src/serve.rs index 3db1d280..860dcd42 100644 --- a/rust/axol-rt/src/serve.rs +++ b/rust/axol-rt/src/serve.rs @@ -405,6 +405,17 @@ fn configure_bus_scheduling( /// start-to-start period gives up an unobservable amount of wall-clock phase /// instead: lateness can lower the average rate briefly, but can never produce /// a catch-up command faster than the configured rate. +/// `(p50, p95, max)` of the per-tick bus-busy fractions since the last stats +/// line; NaN when no tick had a reply. Sorts in place. +fn bus_busy_percentiles(busy: &mut [f64]) -> (f64, f64, f64) { + if busy.is_empty() { + return (f64::NAN, f64::NAN, f64::NAN); + } + busy.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)); + let at = |q: f64| busy[((busy.len() - 1) as f64 * q).round() as usize]; + (at(0.5), at(0.95), busy[busy.len() - 1]) +} + fn next_bus_deadline(began: Instant, period: Duration) -> Instant { began + period } @@ -1255,6 +1266,17 @@ mod tests { assert_eq!(consecutive.record(LATE, PERIOD), TimingVerdict::Degraded); } + #[test] + fn bus_busy_percentiles_report_median_tail_and_peak() { + let mut busy = vec![0.5, 0.7, 0.6, 0.9, 0.55, 0.65, 0.6, 0.62, 0.58, 0.61, 0.95]; + let (p50, p95, max) = bus_busy_percentiles(&mut busy); + assert_eq!(p50, 0.61); + assert_eq!(p95, 0.95); + assert_eq!(max, 0.95); + let (a, b, c) = bus_busy_percentiles(&mut []); + assert!(a.is_nan() && b.is_nan() && c.is_nan()); + } + #[test] fn a4_joints_stay_on_the_position_frame_through_holds_but_not_limp() { use crate::bringup::{Vendor, WireMode}; @@ -2142,6 +2164,13 @@ fn bus_loop( let mut ticks: u64 = 0; let mut watchdog_frozen = false; let mut next_stats = Instant::now() + Duration::from_secs(5); + // Bus occupancy per tick: tick start → last reply received, as a + // fraction of the period. This is what bounds the loop rate — at 240 Hz + // with three a4 joints (two request/reply pairs each) the right arm is + // estimated near three quarters of a 1 Mbps bus — so it is reported in + // the 5 s stats line to size any rate change against a measurement. + let mut bus_busy: Vec = Vec::with_capacity(2048); + let mut bus_last_reply: Option = None; // TX-stall (e-stop) tracking — see `guarded_send`. A dead bus skips the // motor disable on the way out (nothing is powered to hear it, and the // freshly purged queue should stay empty). @@ -2755,6 +2784,7 @@ fn bus_loop( // later, or the overrun lands on the next tick as lateness. let reply_deadline = began + period.saturating_sub(REPLY_GUARD); seen.fill(0); + bus_last_reply = None; let mut pending: usize = expected.iter().map(|&n| n as usize).sum(); while pending > 0 { let now = Instant::now(); @@ -2764,6 +2794,7 @@ fn bus_loop( let Some(frame) = sock.recv_timeout(reply_deadline - now)? else { break; }; + bus_last_reply = Some(Instant::now()); let (idx, pos, vel, tau) = match frame.id { id if (0x501..=0x505).contains(&id) => { let motor_id = (id - 0x500) as u8; @@ -2950,16 +2981,24 @@ fn bus_loop( let _ = out_tx.send(build_feedback(side, &latest, Instant::now())); } + if let Some(t) = bus_last_reply { + bus_busy.push(t.duration_since(began).as_secs_f64() / period.as_secs_f64()); + } if began >= next_stats { next_stats = began + Duration::from_secs(5); + let (busy_p50, busy_p95, busy_max) = bus_busy_percentiles(&mut bus_busy); + bus_busy.clear(); send_text( out_tx, b'L', &format!( - "{iface}: {ticks} ticks, {late} late ({:.2}%), {overruns} overruns, {timing_degraded_ticks} timing-degraded ticks in {timing_degraded_episodes} episodes, {missed} missed replies, {degraded_ticks} feedback-degraded ticks in {degraded_episodes} episodes, {rejected} rejected targets, {held_ticks} held-over ticks (oldest target {:.1} ms, cadence {:.1} ms), {trace_dropped} trace drops, seq {:?}", + "{iface}: {ticks} ticks, {late} late ({:.2}%), {overruns} overruns, {timing_degraded_ticks} timing-degraded ticks in {timing_degraded_episodes} episodes, {missed} missed replies, {degraded_ticks} feedback-degraded ticks in {degraded_episodes} episodes, {rejected} rejected targets, {held_ticks} held-over ticks (oldest target {:.1} ms, cadence {:.1} ms), bus busy p50 {:.0}% p95 {:.0}% max {:.0}% of the tick, {trace_dropped} trace drops, seq {:?}", late as f64 / ticks as f64 * 100.0, worst_target_age * 1e3, cadence.get().unwrap_or(f64::NAN) * 1e3, + busy_p50 * 100.0, + busy_p95 * 100.0, + busy_max * 100.0, last_seq, ), ); From d5b2653f4c1a07c00fcb385d83504fce58edeea1 Mon Sep 17 00:00:00 2001 From: Shawn Patel Date: Tue, 22 Sep 2026 17:39:09 +0000 Subject: [PATCH 28/80] Sweep safety: mirror the shoulder_3 sweep's shoulder_1 raise per arm The humerus-horizontal raise that loads shoulder_3 was a left-arm joint-frame value (+90) applied to both arms. shoulder_1's frame is mirrored (left -90..+180, right -180..+90), so on the right arm +90 is the hard stop, and a right shoulder_3 tune.a4 drove shoulder_1 into it (2026-09-22). The right arm now takes -90. A test pins every sweep's clearance targets, both arms, strictly inside their limits. Co-Authored-By: Claude Fable 5.1 --- almond_axol/tuning/runner.py | 16 ++++++++---- docs/cli/tune-friction.mdx | 2 +- docs/cli/tune-gravity.mdx | 2 +- tests/test_sweep_safety.py | 48 ++++++++++++++++++++++++++++++++++++ 4 files changed, 61 insertions(+), 7 deletions(-) create mode 100644 tests/test_sweep_safety.py diff --git a/almond_axol/tuning/runner.py b/almond_axol/tuning/runner.py index 74ba3d98..14ca3abd 100644 --- a/almond_axol/tuning/runner.py +++ b/almond_axol/tuning/runner.py @@ -114,7 +114,11 @@ def probe_clearance_targets(test_joint: Joint, is_left: bool) -> dict[Joint, flo # Three joints hang axis-vertical and need other joints posed to tilt them. # Clearances below were verified against the torso collision model; signal # figures are the CAD gravity model's torque variation over the sweep. -SHOULDER_1_LOAD = math.radians(90.0) # humerus horizontal for shoulder_3 +# Humerus horizontal for shoulder_3 — a *left-arm* joint-frame value. The +# shoulder_1 frame is mirrored across arms (left −90..+180, right −180..+90), +# so the right arm's copy of this pose is −90°: +90° there is the hard stop, +# and an unmirrored raise drove right shoulder_1 into it (2026-09-22). +SHOULDER_1_LOAD = math.radians(90.0) WRIST_2_LOAD = math.radians(85.0) # hand off wrist_1's axis (85°: limit is 90) WRIST_1_LOAD = math.radians(90.0) # hand off wrist_2's axis # shoulder_3 / wrist_1 sweep cap at their loaded poses: ±90° keeps the bent @@ -185,13 +189,15 @@ def sweep_safety( "the base is inboard." ) elif joint == Joint.SHOULDER_3: - clearance[Joint.SHOULDER_1] = SHOULDER_1_LOAD + s1_load = SHOULDER_1_LOAD if is_left else -SHOULDER_1_LOAD + clearance[Joint.SHOULDER_1] = s1_load clearance[Joint.ELBOW] = elbow_mid lo_cap, hi_cap = -LOADED_SWEEP_CAP, LOADED_SWEEP_CAP notes.append( - "Raising shoulder_1 to 90° and bending the elbow so gravity " - "loads shoulder_3 (its axis is vertical at rest — zero gravity " - "moment there); sweep capped at ±90° to stay clear of the torso." + f"Raising shoulder_1 to {math.degrees(s1_load):+.0f}° and bending the " + "elbow so gravity loads shoulder_3 (its axis is vertical at rest — " + "zero gravity moment there); sweep capped at ±90° to stay clear of " + "the torso." ) elif joint == Joint.WRIST_1: clearance[Joint.ELBOW] = elbow_mid diff --git a/docs/cli/tune-friction.mdx b/docs/cli/tune-friction.mdx index 07d4bf20..56aca1ed 100644 --- a/docs/cli/tune-friction.mdx +++ b/docs/cli/tune-friction.mdx @@ -7,7 +7,7 @@ Identifies the four friction-model parameters for one joint via a bidirectional **Friction model:** `τ = Fc·tanh(0.1·k·v) + Fv·v + Fo` -Sweep safety is built in: shoulder_2 sweeps only its outboard side, capped at 0 (starting at rest is fine — travel past it swings into the base); wrist_2 gets the elbow raised to midpoint, which clears the gripper from the base through its **full** range; and shoulder_3 / wrist_1 sweeps hold shoulder_2 10° outboard so the arm swings clear of the chest cameras. The sweep poses are shared with [`tune.gravity`](/cli/tune-gravity), which additionally needs the test joint gravity-*loaded* — so shoulder_3 sweeps with shoulder_1 raised 90° and the elbow bent (capped ±90°), wrist_1 with the elbow bent and wrist_2 at 85° (capped ±90°), and wrist_2 adds wrist_1 at 90° to the elbow raise. The friction fit is pose-independent (the fwd/bwd half-difference cancels gravity), so sharing the loaded poses costs it nothing. +Sweep safety is built in: shoulder_2 sweeps only its outboard side, capped at 0 (starting at rest is fine — travel past it swings into the base); wrist_2 gets the elbow raised to midpoint, which clears the gripper from the base through its **full** range; and shoulder_3 / wrist_1 sweeps hold shoulder_2 10° outboard so the arm swings clear of the chest cameras. The sweep poses are shared with [`tune.gravity`](/cli/tune-gravity), which additionally needs the test joint gravity-*loaded* — so shoulder_3 sweeps with shoulder_1 raised to humerus-horizontal (+90° on the left arm, −90° on the right — the shoulder_1 frame is mirrored, and +90° is the right arm's hard stop) and the elbow bent (capped ±90°), wrist_1 with the elbow bent and wrist_2 at 85° (capped ±90°), and wrist_2 adds wrist_1 at 90° to the elbow raise. The friction fit is pose-independent (the fwd/bwd half-difference cancels gravity), so sharing the loaded poses costs it nothing. Friction is motor-specific — the shared defaults in `config.py` were measured on one reference robot. Run this per joint, per arm, on every robot you build, and pass `--save` to persist the fit to that machine's calibration file (`~/.almond/calibration.json`). `AxolConfig` loads the file on construction, so saved values apply to every operation on the robot without a code change; explicit config overrides still win over them. diff --git a/docs/cli/tune-gravity.mdx b/docs/cli/tune-gravity.mdx index 2349066e..015639db 100644 --- a/docs/cli/tune-gravity.mdx +++ b/docs/cli/tune-gravity.mdx @@ -9,7 +9,7 @@ Identifies one link's real centre of mass from a friction-cancelled torque sweep **How:** the same bidirectional constant-velocity sweep as [`tune.friction`](/cli/tune-friction) — averaging forward and backward torque at the same position cancels friction exactly, leaving `gravity(q) + Fo`. The residual against the current model is fit to a shift of this link's centre of mass, by ridge-regularized least squares straight through the MuJoCo gravity model, so the correction generalizes to every arm pose. Mass stays at CAD: gravity torque only depends on the first moment `m·c`, so a CoM shift with fixed mass covers every identifiable error. A single-joint sweep is a one-dimensional slice of pose space and can never observe all three CoM components equally — the ridge keeps weakly observed directions at their current value instead of letting them absorb torque noise with a huge lever arm, and a fitted shift beyond 60 mm is rejected as bad data. -**Loaded sweep poses:** gravity has zero moment about a vertical axis, no matter where the mass sits — so a joint whose axis hangs vertical at rest carries no CoM signal at all. Three joints are therefore swept with other joints posed to tilt and load them: `shoulder_3` runs with shoulder_1 raised 90° and the elbow bent; `wrist_1` with the elbow bent and wrist_2 rotated 85°; `wrist_2` keeps the elbow raise (base clearance) and adds wrist_1 at 90° to restore the load the raise removed. Clearance and load poses were verified against the torso collision model, are ramped proximal-first, and feed the model predictions so the fit is computed at the pose the sweep actually ran at. A sweep the model says is still unloaded is refused as unobservable rather than fit to noise. +**Loaded sweep poses:** gravity has zero moment about a vertical axis, no matter where the mass sits — so a joint whose axis hangs vertical at rest carries no CoM signal at all. Three joints are therefore swept with other joints posed to tilt and load them: `shoulder_3` runs with shoulder_1 raised to humerus-horizontal (+90° left, −90° right — the frame is mirrored and +90° is the right arm's hard stop) and the elbow bent; `wrist_1` with the elbow bent and wrist_2 rotated 85°; `wrist_2` keeps the elbow raise (base clearance) and adds wrist_1 at 90° to restore the load the raise removed. Clearance and load poses were verified against the torso collision model, are ramped proximal-first, and feed the model predictions so the fit is computed at the pose the sweep actually ran at. A sweep the model says is still unloaded is refused as unobservable rather than fit to noise. Run **distal → proximal** (`wrist_3` → … → `shoulder_1`): a proximal joint's sweep rotates every distal link with it, so on an uncalibrated arm their errors get lumped into the proximal link's CoM — exact at the sweep pose, approximate once the elbow/wrists bend away from it. The tool prints a note when distal links are still uncalibrated. diff --git a/tests/test_sweep_safety.py b/tests/test_sweep_safety.py new file mode 100644 index 00000000..48e569c4 --- /dev/null +++ b/tests/test_sweep_safety.py @@ -0,0 +1,48 @@ +"""Sweep-safety clearance poses stay inside each arm's joint limits. + +Right shoulder_1 was driven into its +90° hard stop by a shoulder_3 sweep on +2026-09-22: the humerus-horizontal raise was a left-arm value applied to the +mirrored right-arm frame. Every clearance target of every sweep, both arms, +must lie strictly inside the arm's limits, and the mirrored joints' targets +must mirror. +""" + +from __future__ import annotations + +import math +import unittest + +from almond_axol.constants import ARM_JOINTS, Joint +from almond_axol.robot.axol import arm_limits +from almond_axol.tuning.runner import probe_clearance_targets, sweep_safety + + +class ClearancePosesTest(unittest.TestCase): + def test_every_clearance_target_is_inside_its_arm_limits(self) -> None: + margin = math.radians(1.0) + for is_left in (True, False): + for joint in ARM_JOINTS: + targets, _lo, _hi, _notes = sweep_safety(joint, is_left) + targets = {**probe_clearance_targets(joint, is_left), **targets} + for j, q in targets.items(): + lo, hi = arm_limits(j, is_left) + with self.subTest( + arm="left" if is_left else "right", + sweep=joint.value, + held=j.value, + ): + self.assertGreater(q, lo + margin) + self.assertLess(q, hi - margin) + + def test_shoulder_3_sweep_raises_shoulder_1_mirrored(self) -> None: + left, *_ = sweep_safety(Joint.SHOULDER_3, True) + right, *_ = sweep_safety(Joint.SHOULDER_3, False) + self.assertAlmostEqual(left[Joint.SHOULDER_1], math.radians(90.0)) + self.assertAlmostEqual(right[Joint.SHOULDER_1], -math.radians(90.0)) + # And the note tells the operator the signed value the arm will take. + _, _, _, notes = sweep_safety(Joint.SHOULDER_3, False) + self.assertTrue(any("-90°" in n for n in notes)) + + +if __name__ == "__main__": + unittest.main() From 45c62f864ac60efdd918eaaab19f4f154acde166 Mon Sep 17 00:00:00 2001 From: Shawn Patel Date: Tue, 22 Sep 2026 17:40:59 +0000 Subject: [PATCH 29/80] tune.a4: report held joints that moved during the wave The other joints are parked on their own 0xA4 loops and then get no frames for the whole wave. The elbow was found several degrees off rest across runs (2026-09-22); a motor with the communication-interruption protection (0xB3) armed cuts its output when the bus goes quiet on it, which is exactly a wave on another joint. After each wave every held joint's position is compared with its hold target and anything past 1 deg is named. Co-Authored-By: Claude Fable 5.1 --- almond_axol/cli/tune/a4.py | 36 +++++++++++++++++++++++++++++++++++- 1 file changed, 35 insertions(+), 1 deletion(-) diff --git a/almond_axol/cli/tune/a4.py b/almond_axol/cli/tune/a4.py index cf0d056d..3302e749 100644 --- a/almond_axol/cli/tune/a4.py +++ b/almond_axol/cli/tune/a4.py @@ -60,7 +60,7 @@ import numpy as np from ...constants import ARM_JOINTS, Joint -from ...motor import CanBus, ControlMode, Motor +from ...motor import CanBus, ControlMode, Motor, MotorError from ...motor.myactuator import _MA_PID_IDX, MyActuatorMotor from ...tuning import ( JointFrameMotor, @@ -95,6 +95,10 @@ GAIN_NAMES: tuple[str, ...] = tuple(_MA_PID_IDX) +#: A joint held on its own firmware position loop while another joint runs +#: the wave should not move; past this (rad, ~1°) it let go or sagged. +_HOLD_DRIFT_TOL = math.radians(1.0) + #: Position error (rad) past which the wave is abandoned — the loop is not #: following at all (planner in profiled mode, or a runaway). _ERR_ABORT = math.radians(20.0) @@ -697,6 +701,36 @@ async def _run(args: argparse.Namespace) -> None: cap_floor_dps=args.cap_floor, ) live.flush() + # The other joints were parked on their own 0xA4 loops and then + # received no frames for the whole wave. Say so if any let go: + # the elbow was found several degrees off rest across runs + # (2026-09-22), and a motor with the communication-interruption + # protection (0xB3) armed cuts its output when the bus goes quiet + # on it — exactly a wave on another joint. + drifted: list[str] = [] + for j, jm in motors.items(): + if j == joint: + continue + hold = other_targets.get(j, 0.0) + try: + pos = await jm.get_position() + except MotorError: + drifted.append(f"{j.value} (no position reply)") + continue + if abs(pos - hold) > _HOLD_DRIFT_TOL: + drifted.append( + f"{j.value} {math.degrees(pos - hold):+.1f}° off its " + f"{math.degrees(hold):+.0f}° hold" + ) + if drifted: + print( + " ! held joints moved during the wave: " + + ", ".join(drifted) + + " — a joint that lets go while another is streamed points at " + "its communication-interruption protection (0xB3: output cut " + "after N ms without a frame); the tuner sends held joints " + "nothing during the wave" + ) if reason is not None: print(f"\n ! aborted: {reason}") if before_gains is not None: From c6ecc4738da04e331d4d592b39430e7f38571299 Mon Sep 17 00:00:00 2001 From: Shawn Patel Date: Tue, 22 Sep 2026 17:48:15 +0000 Subject: [PATCH 30/80] tune.a4: --pose holds, and the held joints are sampled and scored during the wave MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Right shoulder_2 oscillated while held 10° outboard during a shoulder_3 sweep (2026-09-22): shoulder_1 raised to horizontal and the elbow bent put the whole extended arm on shoulder_2's axis, several times the reflected inertia its gains (1.0 / 0.07) were tuned against with the arm hanging. A firmware position loop well damped in one pose can be underdamped in another, and teleop visits both. Two tool changes. `--pose JOINT=DEG` (tune.pid's flag and rules) holds other joints at chosen angles so a joint can be swept in its worst-case pose, overriding the sweep's clearance pose for that joint; the dashboard's Firmware-loop tab gets the pose editor and re-arms it from a saved run. And every held joint is now sampled round-robin during the wave — one 0x92 read of one held joint per tick — and scored on drift from its hold, peak-to-peak, std and dominant frequency, printed after the wave and saved under metrics.held, so a held joint that oscillates or lets go is recorded rather than only seen. Co-Authored-By: Claude Fable 5.1 --- almond_axol/cli/tune/a4.py | 182 +++++++++++++++++- docs/cli/tune-a4.mdx | 1 + tests/test_tune_a4.py | 41 ++++ .../diagnostics/tuning-workbench.tsx | 12 ++ 4 files changed, 230 insertions(+), 6 deletions(-) diff --git a/almond_axol/cli/tune/a4.py b/almond_axol/cli/tune/a4.py index 3302e749..c8f0c8f5 100644 --- a/almond_axol/cli/tune/a4.py +++ b/almond_axol/cli/tune/a4.py @@ -62,6 +62,7 @@ from ...constants import ARM_JOINTS, Joint from ...motor import CanBus, ControlMode, Motor, MotorError from ...motor.myactuator import _MA_PID_IDX, MyActuatorMotor +from ...robot.axol import arm_limits from ...tuning import ( JointFrameMotor, joint_frame_motors, @@ -281,6 +282,106 @@ def a4_metrics(log: list[dict], rate: float) -> dict[str, Any]: # --------------------------------------------------------------------------- +def parse_pose( + specs: list[str] | None, joint: Joint, is_left: bool +) -> dict[Joint, float]: + """``--pose JOINT=DEG`` flags → joint-frame hold targets (rad), validated. + + Same rules as ``tune.pid``: known arm joint, not the test joint, inside + the arm's limits, shoulder_2 outboard only (the base is inboard), and + wrist_2's inboard half only with the elbow bent ≥ 30°. + + Why a pose here at all: a firmware position loop that is well damped + with the arm hanging can go underdamped with the arm extended — the + reflected inertia about shoulder_2 is several times larger with + shoulder_1 raised and the elbow bent — and right shoulder_2 was seen + oscillating on exactly that hold during a shoulder_3 sweep + (2026-09-22). Tune the worst-case pose, not just the rest pose. + """ + side = "left" if is_left else "right" + pose: dict[Joint, float] = {} + for spec in specs or []: + name, _, deg = spec.partition("=") + try: + pj = Joint(name) + except ValueError: + raise SystemExit(f"--pose: unknown joint {name!r}") from None + if pj == joint: + raise SystemExit(f"--pose: {name} is the test joint") + if pj not in ARM_JOINTS: + raise SystemExit(f"--pose: {name} is not an arm joint") + try: + rad = math.radians(float(deg)) + except ValueError: + raise SystemExit( + f"--pose: bad angle in {spec!r} (want JOINT=DEG)" + ) from None + lo, hi = arm_limits(pj, is_left) + if not (lo <= rad <= hi): + raise SystemExit( + f"--pose: {name}={deg}° is outside " + f"[{math.degrees(lo):.0f}, {math.degrees(hi):.0f}]° for the {side} arm" + ) + pose[pj] = rad + s2 = pose.get(Joint.SHOULDER_2) + s2_out = -1.0 if is_left else 1.0 + if s2 is not None and s2 * s2_out < 0: + raise SystemExit( + f"--pose: shoulder_2 must stay outboard " + f"({'negative' if s2_out < 0 else 'positive'} on the {side} arm) — " + "the robot base is inboard" + ) + w2 = pose.get(Joint.WRIST_2) + w2_out = 1.0 if is_left else -1.0 + if w2 is not None and w2 * w2_out < 0: + elbow_pose = pose.get(Joint.ELBOW) + if elbow_pose is None or abs(elbow_pose) < math.radians(30.0): + raise SystemExit( + "--pose: wrist_2 posed in its inboard half meets the base with the " + "elbow straight — pose the elbow bent too, e.g. --pose elbow=75" + ) + return pose + + +def held_summary( + held_log: dict[str, list[tuple[float, float]]], holds: dict[str, float] +) -> dict[str, dict[str, float]]: + """Score each held joint's motion during the wave. + + Per joint (degrees): ``drift`` = mean position minus its hold, ``p2p`` + = peak-to-peak excursion, ``std`` = standard deviation, ``hz`` = + dominant frequency of that motion (NaN below ~1 s of samples). A held + joint sits on its own firmware position loop: std above a few + hundredths of a degree with a clear ``hz`` is the loop oscillating in + that pose; a large ``drift`` with little ``std`` is a joint that let go. + """ + out: dict[str, dict[str, float]] = {} + for name, samples in held_log.items(): + if len(samples) < 4: + continue + t = np.array([a for a, _ in samples]) + q = np.degrees(np.array([b for _, b in samples])) + hold = math.degrees(holds.get(name, 0.0)) + row = { + "drift": float(q.mean() - hold), + "p2p": float(np.ptp(q)), + "std": float(q.std()), + "hz": math.nan, + } + if t[-1] - t[0] > 1.0: + fs = (len(t) - 1) / (t[-1] - t[0]) + tu = np.arange(t[0], t[-1], 1.0 / fs) + x = np.interp(tu, t, q) + x = x - x.mean() + F = np.abs(np.fft.rfft(x * np.hanning(len(x)))) + f = np.fft.rfftfreq(len(x), 1.0 / fs) + m = f >= 0.5 + if m.any() and F[m].max() > 0: + row["hz"] = float(f[m][int(np.argmax(F[m]))]) + out[name] = row + return out + + def _a4_frame(position_rad: float, cap_dps: float) -> bytes: cap = int(max(0.0, min(65535.0, round(cap_dps)))) return ( @@ -369,14 +470,24 @@ async def _stream( live: LiveStream, cap_track: float = 0.0, cap_floor_dps: float = 1.0, -) -> tuple[list[dict], str | None]: - """Stream the wave; returns the log and the abort reason, if any.""" + held: dict[Joint, JointFrameMotor] | None = None, +) -> tuple[list[dict], str | None, dict[str, list[tuple[float, float]]]]: + """Stream the wave; returns the log, the abort reason (if any), and the + held joints' ``{joint: [(t, position_rad), ...]}`` sampled round-robin — + one 0x92 read of one held joint per tick, so each is seen at + ``rate / len(held)`` Hz and the wave's own two round trips stay first.""" offset = motor.offset period = 1.0 / rate log: list[dict] = [] + held_items = [ + (j.value, jm.motor._driver, jm.offset) + for j, jm in (held or {}).items() + if isinstance(jm.motor._driver, MyActuatorMotor) + ] + held_log: dict[str, list[tuple[float, float]]] = {n: [] for n, _, _ in held_items} t0 = time.perf_counter() deadline = t0 - for _t_nominal, target, v_cmd in samples: + for k, (_t_nominal, target, v_cmd) in enumerate(samples): deadline += period cap = speed_cap(v_cmd, cap_dps, cap_track, cap_floor_dps) resp = await driver._request(_a4_frame(target - offset, cap)) @@ -384,6 +495,21 @@ async def _stream( fine = await driver._request(bytes([_MA_MULTI_TURN_ANGLE, 0, 0, 0, 0, 0, 0, 0])) pos = struct.unpack_from(" _ERR_ABORT: reason = f"tracking error {math.degrees(pos - target):+.1f}° — the loop is not following" if reason is not None: - return log, reason + return log, reason, held_log await asyncio.sleep(max(0.0, deadline - time.perf_counter())) - return log, None + return log, None, held_log async def _hold( @@ -476,6 +602,18 @@ def add_parser(subparsers: argparse._SubParsersAction) -> None: # type: ignore[ p.add_argument( "--cap", type=float, default=60.0, help="0xA4 speed cap, deg/s (default: 60)" ) + p.add_argument( + "--pose", + action="append", + default=None, + metavar="JOINT=DEG", + help="Hold another joint at this joint-frame angle (degrees) during the run, " + "e.g. --pose shoulder_1=-90 --pose elbow=-75 (repeatable; overrides the sweep's " + "own clearance pose for that joint). A firmware loop that is well damped with " + "the arm hanging can oscillate with it extended — right shoulder_2 did, held " + "during a shoulder_3 sweep — so tune the worst-case pose too. Posed joints " + "return to rest afterwards.", + ) p.add_argument( "--cap-track", type=float, @@ -604,6 +742,7 @@ async def _run(args: argparse.Namespace) -> None: reason: str | None = None used_gains: dict[str, float] = {} accel_used: tuple[int, int] | None = None + held_scores: dict[str, dict[str, float]] = {} # Planner acceleration goes in *before* the mode switch below: that # switch is a 0x76 reset, and the reset is what makes a planner value @@ -661,6 +800,15 @@ async def _run(args: argparse.Namespace) -> None: other_targets, _lo, _hi, notes = sweep_safety(joint, is_left) for note in notes: print(f" {note}") + pose = parse_pose(args.pose, joint, is_left) + if pose: + other_targets.update(pose) + print( + " Posing " + + ", ".join( + f"{j.value} at {math.degrees(q):+.0f}°" for j, q in pose.items() + ) + ) for stage in ramp_stages(other_targets): await _ramp_verified(motors, stage) print(f" Ramping {joint.value} to {math.degrees(center):+.1f}° ...") @@ -689,7 +837,7 @@ async def _run(args: argparse.Namespace) -> None: guard = BuzzGuard(args.rate, math.radians(args.buzz_abort), args.iq_abort) live = LiveStream("sine", joint) print(" Running ...") - log, reason = await _stream( + log, reason, held_log = await _stream( motor, driver, samples, @@ -699,8 +847,27 @@ async def _run(args: argparse.Namespace) -> None: live, cap_track=cap_track, cap_floor_dps=args.cap_floor, + held={j: jm for j, jm in motors.items() if j != joint}, ) live.flush() + held_scores = held_summary( + held_log, {j.value: q for j, q in other_targets.items()} + ) + if held_scores: + print( + " held joints during the wave (drift / p2p / std / dominant Hz):" + ) + for name, r in held_scores.items(): + flag = ( + " <-- oscillating" + if r["std"] > 0.05 and r["hz"] == r["hz"] + else "" + ) + print( + f" {name:12s} {r['drift']:+6.2f}° / {r['p2p']:5.2f}° / {r['std']:5.3f}° / " + + (f"{r['hz']:4.1f} Hz" if r["hz"] == r["hz"] else " — ") + + flag + ) # The other joints were parked on their own 0xA4 loops and then # received no frames for the whole wave. Say so if any let go: # the elbow was found several degrees off rest across runs @@ -792,6 +959,8 @@ async def _run(args: argparse.Namespace) -> None: return metrics = a4_metrics(log, args.rate) metrics["aborted"] = reason is not None + if held_scores: + metrics["held"] = held_scores print(f"\n{'─' * 66}") print( f" tracking RMS {math.degrees(metrics['rms']):.3f}° max {math.degrees(metrics['max']):.3f}° lag {metrics['lag_ms']:.0f} ms" @@ -823,6 +992,7 @@ async def _run(args: argparse.Namespace) -> None: "cap_floor_dps": args.cap_floor, "accel": list(accel_used) if accel_used else None, "persist": args.persist, + "pose": args.pose or None, } run_id = save_run( "sine", diff --git a/docs/cli/tune-a4.mdx b/docs/cli/tune-a4.mdx index 92e64848..dca0fc5a 100644 --- a/docs/cli/tune-a4.mdx +++ b/docs/cli/tune-a4.mdx @@ -28,6 +28,7 @@ Safety, built in: | `--duration S` | Seconds of wave (default: 12) | | `--rate HZ` | Command rate (default: 400, the rate the realtime core streams a4 joints at; tune at the rate you will run — the step size is the excitation, and 200 Hz put an audible staircase on the shoulder at 12 deg/s that 400 removed) | | `--cap DPS` | 0xA4 speed cap (default: 60) | +| `--pose JOINT=DEG` | Hold another joint at this joint-frame angle during the run, repeatable, overriding the sweep's own clearance pose for that joint (same rules as [`tune.pid`](/cli/tune-pid): inside the arm's limits, shoulder_2 outboard only). A firmware loop that is well damped with the arm hanging can oscillate with it extended — right shoulder_2 did, held 10° outboard with shoulder_1 raised and the elbow bent during a shoulder_3 sweep — so tune the worst-case pose too. Every held joint is sampled round-robin during the wave (one 0x92 read per tick) and scored: drift from its hold, peak-to-peak, std and dominant frequency, printed and saved under `metrics.held` | | `--cap-track K` | Per-command cap = `K × |commanded speed|`, floored at `--cap-floor`, never above `--cap` (default: 0 = fixed cap). With `--accel 60000` a fixed cap lets the planner burst through each 200 Hz step at the cap and idle the rest of the tick — 4× the current spread on the elbow, 68–82 Hz velocity content; `1.1`–`1.2` keeps the joint moving continuously at about the commanded speed. Ignored (with a warning) when the planner is at 0: under direct tracking the cap is a hard limit on the PI output and would only throttle the loop | | `--cap-floor DPS` | Lowest cap `--cap-track` may set, so a stationary or reversing target still corrects (default: 1) | | `--accel DPS/S` | Planner acceleration for the run, written to ROM before the mode-switch reset and restored afterwards unless `--keep`. `0` = direct PI tracking; `60000` = step-follow (see above); values in between will not follow a stream | diff --git a/tests/test_tune_a4.py b/tests/test_tune_a4.py index 3b52ea02..811cfdc1 100644 --- a/tests/test_tune_a4.py +++ b/tests/test_tune_a4.py @@ -188,3 +188,44 @@ def test_mode_current_isolates_the_3_to_8_hz_shudder(self) -> None: # The 3-8 Hz share is the 5 Hz tone's std alone: 1/sqrt(2) ≈ 0.707 A. self.assertAlmostEqual(m["iq_mode"], 1.0 / math.sqrt(2), places=1) self.assertLess(m["iq_mode"], m["iq_sd"]) + + +class PoseAndHeldJointsTest(unittest.TestCase): + def test_pose_parses_validates_and_mirrors_shoulder_2_outboard(self) -> None: + from almond_axol.constants import Joint + + pose = a4.parse_pose(["shoulder_1=-90", "elbow=-75"], Joint.SHOULDER_2, False) + self.assertAlmostEqual(pose[Joint.SHOULDER_1], math.radians(-90)) + self.assertAlmostEqual(pose[Joint.ELBOW], math.radians(-75)) + self.assertEqual(a4.parse_pose(None, Joint.ELBOW, True), {}) + with self.assertRaisesRegex(SystemExit, "outside"): + a4.parse_pose(["shoulder_1=120"], Joint.ELBOW, False) # right limit is +90 + with self.assertRaisesRegex(SystemExit, "test joint"): + a4.parse_pose(["elbow=10"], Joint.ELBOW, False) + with self.assertRaisesRegex(SystemExit, "outboard"): + a4.parse_pose( + ["shoulder_2=-10"], Joint.SHOULDER_3, False + ) # right: positive + with self.assertRaisesRegex(SystemExit, "unknown joint"): + a4.parse_pose(["hip=1"], Joint.ELBOW, True) + + def test_held_summary_scores_drift_and_oscillation(self) -> None: + t = np.arange(0, 4.0, 1 / 80) + # shoulder_2 oscillating at 2.5 Hz, ±0.5°, around a +10° hold. + s2 = math.radians(10) + math.radians(0.5) * np.sin(2 * math.pi * 2.5 * t) + # elbow let go: parked 6° below its 0° hold, no oscillation. + el = np.full_like(t, math.radians(-6.0)) + out = a4.held_summary( + { + "shoulder_2": list(zip(t, s2)), + "elbow": list(zip(t, el)), + "wrist_1": [(0.0, 0.0)], + }, + {"shoulder_2": math.radians(10), "elbow": 0.0}, + ) + self.assertNotIn("wrist_1", out) # too few samples to score + self.assertAlmostEqual(out["shoulder_2"]["hz"], 2.5, delta=0.3) + self.assertAlmostEqual(out["shoulder_2"]["p2p"], 1.0, delta=0.05) + self.assertAlmostEqual(out["shoulder_2"]["drift"], 0.0, delta=0.05) + self.assertAlmostEqual(out["elbow"]["drift"], -6.0, places=6) + self.assertEqual(out["elbow"]["std"], 0.0) diff --git a/web/app/src/components/diagnostics/tuning-workbench.tsx b/web/app/src/components/diagnostics/tuning-workbench.tsx index e6ece49d..b7dd674e 100644 --- a/web/app/src/components/diagnostics/tuning-workbench.tsx +++ b/web/app/src/components/diagnostics/tuning-workbench.tsx @@ -364,6 +364,17 @@ const TABS: WbTab[] = [ hint: "joint-frame centre (0 = rest); probe under gravity load, e.g. -35 on shoulder_1", }, { key: "amp", label: "half-travel (°)", type: "number", placeholder: "10" }, + { + key: "pose", + label: "pose — hold other joints (°)", + type: "pose", + hint: + "hold other joints at an angle during the run (overrides the sweep's own " + + "clearance pose for that joint). A firmware loop that is well damped with the " + + "arm hanging can oscillate with it extended — right shoulder_2 did, held during " + + "a shoulder_3 sweep — so tune the worst-case pose too; the held joints are " + + "sampled during the wave and scored", + }, { key: "speed", label: "triangle speed (°/s)", type: "number", placeholder: "3" }, { key: "freq", label: "sine freq (Hz)", type: "number", placeholder: "0.3" }, { key: "duration", label: "duration (s)", type: "number", placeholder: "12" }, @@ -1153,6 +1164,7 @@ function runFormValues(meta: TuningRunMeta): Record | null { if (typeof p.cap_track === "number" && p.cap_track > 0) put("cap_track", p.cap_track) if (typeof p.cap_track === "number" && p.cap_track > 0) put("cap_floor", p.cap_floor_dps) if (Array.isArray(p.accel) && typeof p.accel[0] === "number") out["accel"] = String(p.accel[0]) + if (Array.isArray(p.pose) && p.pose.length > 0) out["pose"] = p.pose.join(" ") for (const k of ["position_kp", "position_ki", "position_kd", "speed_kp", "speed_ki"]) { const v = g[k] if (typeof v === "number" && Number.isFinite(v)) out[k] = fmtFwGain(v) From 159b61f54ad68fa860efdbc92e9bf0b84276334d Mon Sep 17 00:00:00 2001 From: Shawn Patel Date: Tue, 22 Sep 2026 17:57:37 +0000 Subject: [PATCH 31/80] Firmware loop gains for shoulder_3 (X6-P20): position_kp 1.0, speed_kp 0.05 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit At stock (position_kp 0.06, speed_kp 0.01) shoulder_3 is near-limp under a4: held at rest during a shoulder_2 sweep with the arm extended it wobbled 1 deg peak-to-peak at ~3 Hz whenever the arm moved (2026-09-22) — the oscillation first taken for shoulder_2. 1.0 / 0.05 tracked a 3 deg/s triangle at 0.04 deg RMS, 9 ms lag, tone 0.06 A on the 400 Hz stream. Written to ROM at enable like the others. Co-Authored-By: Claude Fable 5.1 --- almond_axol/robot/config.py | 14 ++++++++++++++ docs/snippets/config/robot.mdx | 8 ++++---- tests/test_firmware_gains.py | 27 ++++++++++++++++++++------- 3 files changed, 38 insertions(+), 11 deletions(-) diff --git a/almond_axol/robot/config.py b/almond_axol/robot/config.py index 78885bf3..0ebc7d35 100644 --- a/almond_axol/robot/config.py +++ b/almond_axol/robot/config.py @@ -387,6 +387,19 @@ class PositionForceConfig: speed_ki=1e-5, ) +# shoulder_3 (RMD-X6-P20, same firmware; stock position_kp 0.06, speed_kp +# 0.01, position_kd 0.5). At stock it is near-limp under a4: held at rest +# during a shoulder_2 sweep with the arm extended it wobbled 1° peak-to-peak +# at ~3 Hz whenever the arm moved (2026-09-22). 1.0 / 0.05 — the elbow's +# speed gain, one stiffness step below the elbow — tracked a 3 deg/s +# triangle at 0.04° RMS, 9 ms lag, tone 0.06 A, on the 400 Hz stream. +_X6_SHOULDER_3_FIRMWARE_GAINS = FirmwareGains( + position_kp=1.0, + position_kd=0.5, + speed_kp=0.05, + speed_ki=1e-5, +) + @dataclass class ArmConfig: @@ -468,6 +481,7 @@ class ArmConfig: # though wrist_2 has no host damping. Narrowing shoulder_3 from # Q=0.8 to Q=3 did not remove it. Keep damping on the motor side; # do not chase the coupled wrist symptom with another host term. + firmware=_X6_SHOULDER_3_FIRMWARE_GAINS, ) ) elbow: JointConfig = field( diff --git a/docs/snippets/config/robot.mdx b/docs/snippets/config/robot.mdx index 3d63d5e1..7c780f64 100644 --- a/docs/snippets/config/robot.mdx +++ b/docs/snippets/config/robot.mdx @@ -32,11 +32,11 @@ export const F = ({ children }) => {child | --{prefix}left.elbow.friction.fv | per motor | Viscous friction (Nm·s/rad). | | --{prefix}left.elbow.friction.fo | per motor | Constant torque offset (Nm). | | --{prefix}left.elbow.friction.fl | `0.0` | Load-proportional Coulomb friction, Nm per Nm of gravity feed-forward: the effective `fc` is `fc + fl × |gravity|`. Planetary gear friction grows with the torque it carries (right shoulder_1: ~0.6 Nm sliding at rest, ~1.5 Nm under 12 Nm), so a constant `fc` over-compensates at rest and under-compensates at reach. `0` keeps the constant model. | -| --{prefix}left.elbow.firmware.position_kp | `null` (shoulders `1.0`, elbow `1.4`) | **MyActuator firmware** position-loop proportional gain, written to the motor's ROM at enable when set (`null` leaves the motor's stored value). These five gains are the motor's own position PI → speed PI controller and only act under `wire_mode` `a4`; identify them with [`tune.a4`](/cli/tune-a4). Stock 0.008 on the X8-P20 shoulders stick-slips at creep speed; the stairs are gone by 0.2 and, with the 400 Hz stream the core runs, tracking keeps improving to 1.0 (0.17° / 12 ms at 12 deg/s) with 1.4 still clean. | +| --{prefix}left.elbow.firmware.position_kp | `null` (shoulders `1.0`, shoulder_3 `1.0`, elbow `1.4`) | **MyActuator firmware** position-loop proportional gain, written to the motor's ROM at enable when set (`null` leaves the motor's stored value). These five gains are the motor's own position PI → speed PI controller and only act under `wire_mode` `a4`; identify them with [`tune.a4`](/cli/tune-a4). Stock 0.008 on the X8-P20 shoulders stick-slips at creep speed; the stairs are gone by 0.2 and, with the 400 Hz stream the core runs, tracking keeps improving to 1.0 (0.17° / 12 ms at 12 deg/s) with 1.4 still clean. | | --{prefix}left.elbow.firmware.position_ki | `null` | Firmware position-loop integral gain. Leave at the stock `0`: on top of the speed integrator it hunts around the target. | -| --{prefix}left.elbow.firmware.position_kd | `null` (shoulders and elbow `0.1`) | Firmware position-loop derivative gain (protocol V4.2+). The motor stores it, but the 0xA4 loop on the X8-P20 measured inert to it. | -| --{prefix}left.elbow.firmware.speed_kp | `null` (shoulders `0.07`, elbow `0.05`) | Firmware speed-loop proportional gain. Not a damper in practice — it did nothing for the loop's 5 Hz reversal mode — but the knob on the speed loop's own resonance (~100 Hz on the X8-P20, ~135 Hz on the X6-P20): the current tone grows with it until the loop goes unstable (0.2 on the shoulder, 0.1 at position_kp 1.5 on the elbow). Lower is quieter at no tracking cost. | -| --{prefix}left.elbow.firmware.speed_ki | `null` (shoulders and elbow `1e-05`) | Firmware speed-loop integral gain. Lower than the stock `1e-4` is smoother on a geared joint: the integrator winds up while the joint is stuck and dumps it at release. | +| --{prefix}left.elbow.firmware.position_kd | `null` (shoulders and elbow `0.1`, shoulder_3 `0.5`) | Firmware position-loop derivative gain (protocol V4.2+). The motor stores it, but the 0xA4 loop on the X8-P20 measured inert to it. | +| --{prefix}left.elbow.firmware.speed_kp | `null` (shoulders `0.07`, shoulder_3 and elbow `0.05`) | Firmware speed-loop proportional gain. Not a damper in practice — it did nothing for the loop's 5 Hz reversal mode — but the knob on the speed loop's own resonance (~100 Hz on the X8-P20, ~135 Hz on the X6-P20): the current tone grows with it until the loop goes unstable (0.2 on the shoulder, 0.1 at position_kp 1.5 on the elbow). Lower is quieter at no tracking cost. | +| --{prefix}left.elbow.firmware.speed_ki | `null` (shoulders, shoulder_3 and elbow `1e-05`) | Firmware speed-loop integral gain. Lower than the stock `1e-4` is smoother on a geared joint: the integrator winds up while the joint is stuck and dumps it at release. | Per-arm and per-joint flags are shown for `left` / `elbow`. Swap in `right`, or any joint: `shoulder_1`, `shoulder_2`, `shoulder_3`, `elbow`, `wrist_1`, `wrist_2`, `wrist_3`. diff --git a/tests/test_firmware_gains.py b/tests/test_firmware_gains.py index 33659602..79e6cc55 100644 --- a/tests/test_firmware_gains.py +++ b/tests/test_firmware_gains.py @@ -40,9 +40,22 @@ def test_elbow_carries_its_own_set_on_both_arms(self) -> None: }, ) + def test_shoulder_3_carries_its_own_set_on_both_arms(self) -> None: + cfg = AxolConfig() + for arm in (cfg.left, cfg.right): + self.assertEqual( + arm.shoulder_3.firmware.as_dict(), + { + "position_kp": 1.0, + "position_kd": 0.5, + "speed_kp": 0.05, + "speed_ki": 1e-5, + }, + ) + def test_other_joints_leave_the_motor_alone(self) -> None: arm = AxolConfig().left - for name in ("shoulder_3", "wrist_1", "wrist_2", "wrist_3"): + for name in ("wrist_1", "wrist_2", "wrist_3"): self.assertEqual(getattr(arm, name).firmware.as_dict(), {}) def test_defaults_survive_the_stiffness_blend(self) -> None: @@ -174,18 +187,18 @@ async def asyncSetUp(self) -> None: async def test_cold_configured_joints_get_their_gains_and_others_are_untouched( self, ) -> None: - s1, s2, elbow, s3 = (_FakeMotor(_stock()) for _ in range(4)) + s1, s2, elbow, w1 = (_FakeMotor(_stock()) for _ in range(4)) arm = _arm( { Joint.SHOULDER_1: s1, Joint.SHOULDER_2: s2, Joint.ELBOW: elbow, - Joint.SHOULDER_3: s3, + Joint.WRIST_1: w1, } ) with self.assertLogs("almond_axol.robot.axol", level="INFO") as logs: await apply_firmware_gains( - arm, [Joint.SHOULDER_1, Joint.SHOULDER_2, Joint.ELBOW, Joint.SHOULDER_3] + arm, [Joint.SHOULDER_1, Joint.SHOULDER_2, Joint.ELBOW, Joint.WRIST_1] ) for motor in (s1, s2): self.assertAlmostEqual(motor.store[_MA_PID_IDX["position_kp"]], 1.0, 6) @@ -197,12 +210,12 @@ async def test_cold_configured_joints_get_their_gains_and_others_are_untouched( self.assertAlmostEqual(motor.store[_MA_PID_IDX["speed_ki"]], 1e-5, 9) # position_kd 0.1 is already the stock value: read, never written. self.assertNotIn(_MA_PID_IDX["position_kd"], [i for i, _ in motor.writes]) - # shoulder_3 has no firmware block configured. - self.assertEqual(s3.writes, []) + # wrist_1 has no firmware block configured. + self.assertEqual(w1.writes, []) self.assertEqual(sum("written to ROM" in m for m in logs.output), 3) # Every motor that took a write is rebooted so its loop loads the new # gains; the untouched one is not. - self.assertEqual([s1.resets, s2.resets, elbow.resets, s3.resets], [1, 1, 1, 0]) + self.assertEqual([s1.resets, s2.resets, elbow.resets, w1.resets], [1, 1, 1, 0]) async def test_a_provisioned_motor_is_neither_written_nor_reset(self) -> None: s1 = _FakeMotor(_stock()) From 34d157e1698a5321201d5777b39f4400f585193d Mon Sep 17 00:00:00 2001 From: Shawn Patel Date: Tue, 22 Sep 2026 18:24:12 +0000 Subject: [PATCH 32/80] Firmware loop gains for wrist_1: the X6 roll set (position_kp 1.0, speed_kp 0.05) Same motor, firmware and stock gains as shoulder_3, same knee: at stock a 12 deg/s triangle tracked 124 ms late (1.64 deg RMS); 1.0 / 0.05 gives 0.12 deg / 10 ms with the 67 Hz speed-loop tone at 0.04 A (0.025 -> 0.071 A from 0.7 -> 1.4) and sine ripple 0.05. shoulder_3 and wrist_1 now share one constant. Written to both arms' ROM; the enable hook keeps it there. Co-Authored-By: Claude Fable 5.1 --- almond_axol/robot/config.py | 20 +++++++++++-------- docs/snippets/config/robot.mdx | 8 ++++---- tests/test_firmware_gains.py | 36 ++++++++++++++++++---------------- 3 files changed, 35 insertions(+), 29 deletions(-) diff --git a/almond_axol/robot/config.py b/almond_axol/robot/config.py index 0ebc7d35..51a9b7a2 100644 --- a/almond_axol/robot/config.py +++ b/almond_axol/robot/config.py @@ -387,13 +387,16 @@ class PositionForceConfig: speed_ki=1e-5, ) -# shoulder_3 (RMD-X6-P20, same firmware; stock position_kp 0.06, speed_kp -# 0.01, position_kd 0.5). At stock it is near-limp under a4: held at rest -# during a shoulder_2 sweep with the arm extended it wobbled 1° peak-to-peak -# at ~3 Hz whenever the arm moved (2026-09-22). 1.0 / 0.05 — the elbow's -# speed gain, one stiffness step below the elbow — tracked a 3 deg/s -# triangle at 0.04° RMS, 9 ms lag, tone 0.06 A, on the 400 Hz stream. -_X6_SHOULDER_3_FIRMWARE_GAINS = FirmwareGains( +# shoulder_3 and wrist_1 (both RMD-X6-P20 on the same firmware, identical +# stock gains: position_kp 0.06, speed_kp 0.01, position_kd 0.5). At stock +# they are near-limp under a4: shoulder_3 held at rest during a shoulder_2 +# sweep with the arm extended wobbled 1° peak-to-peak at ~3 Hz whenever the +# arm moved, and wrist_1 tracked a 12 deg/s triangle 124 ms late (2026-09-22). +# 1.0 / 0.05 — the elbow's speed gain, one stiffness step below the elbow — +# is the knee on both at the 400 Hz stream: shoulder_3 0.04° RMS / 9 ms at +# 3 deg/s with the tone at 0.06 A (1.4 doubled it); wrist_1 0.12° / 10 ms at +# 12 deg/s, tone 0.04 A (0.025 → 0.071 A from 0.7 → 1.4), sine ripple 0.05. +_X6_ROLL_FIRMWARE_GAINS = FirmwareGains( position_kp=1.0, position_kd=0.5, speed_kp=0.05, @@ -481,7 +484,7 @@ class ArmConfig: # though wrist_2 has no host damping. Narrowing shoulder_3 from # Q=0.8 to Q=3 did not remove it. Keep damping on the motor side; # do not chase the coupled wrist symptom with another host term. - firmware=_X6_SHOULDER_3_FIRMWARE_GAINS, + firmware=_X6_ROLL_FIRMWARE_GAINS, ) ) elbow: JointConfig = field( @@ -509,6 +512,7 @@ class ArmConfig: friction=_ZERO_FRICTION, mass=0.25, com=(0.0, 0.0, -0.0614121), + firmware=_X6_ROLL_FIRMWARE_GAINS, ) ) wrist_2: JointConfig = field( diff --git a/docs/snippets/config/robot.mdx b/docs/snippets/config/robot.mdx index 7c780f64..e6cced8f 100644 --- a/docs/snippets/config/robot.mdx +++ b/docs/snippets/config/robot.mdx @@ -32,11 +32,11 @@ export const F = ({ children }) => {child | --{prefix}left.elbow.friction.fv | per motor | Viscous friction (Nm·s/rad). | | --{prefix}left.elbow.friction.fo | per motor | Constant torque offset (Nm). | | --{prefix}left.elbow.friction.fl | `0.0` | Load-proportional Coulomb friction, Nm per Nm of gravity feed-forward: the effective `fc` is `fc + fl × |gravity|`. Planetary gear friction grows with the torque it carries (right shoulder_1: ~0.6 Nm sliding at rest, ~1.5 Nm under 12 Nm), so a constant `fc` over-compensates at rest and under-compensates at reach. `0` keeps the constant model. | -| --{prefix}left.elbow.firmware.position_kp | `null` (shoulders `1.0`, shoulder_3 `1.0`, elbow `1.4`) | **MyActuator firmware** position-loop proportional gain, written to the motor's ROM at enable when set (`null` leaves the motor's stored value). These five gains are the motor's own position PI → speed PI controller and only act under `wire_mode` `a4`; identify them with [`tune.a4`](/cli/tune-a4). Stock 0.008 on the X8-P20 shoulders stick-slips at creep speed; the stairs are gone by 0.2 and, with the 400 Hz stream the core runs, tracking keeps improving to 1.0 (0.17° / 12 ms at 12 deg/s) with 1.4 still clean. | +| --{prefix}left.elbow.firmware.position_kp | `null` (shoulders `1.0`, shoulder_3 and wrist_1 `1.0`, elbow `1.4`) | **MyActuator firmware** position-loop proportional gain, written to the motor's ROM at enable when set (`null` leaves the motor's stored value). These five gains are the motor's own position PI → speed PI controller and only act under `wire_mode` `a4`; identify them with [`tune.a4`](/cli/tune-a4). Stock 0.008 on the X8-P20 shoulders stick-slips at creep speed; the stairs are gone by 0.2 and, with the 400 Hz stream the core runs, tracking keeps improving to 1.0 (0.17° / 12 ms at 12 deg/s) with 1.4 still clean. | | --{prefix}left.elbow.firmware.position_ki | `null` | Firmware position-loop integral gain. Leave at the stock `0`: on top of the speed integrator it hunts around the target. | -| --{prefix}left.elbow.firmware.position_kd | `null` (shoulders and elbow `0.1`, shoulder_3 `0.5`) | Firmware position-loop derivative gain (protocol V4.2+). The motor stores it, but the 0xA4 loop on the X8-P20 measured inert to it. | -| --{prefix}left.elbow.firmware.speed_kp | `null` (shoulders `0.07`, shoulder_3 and elbow `0.05`) | Firmware speed-loop proportional gain. Not a damper in practice — it did nothing for the loop's 5 Hz reversal mode — but the knob on the speed loop's own resonance (~100 Hz on the X8-P20, ~135 Hz on the X6-P20): the current tone grows with it until the loop goes unstable (0.2 on the shoulder, 0.1 at position_kp 1.5 on the elbow). Lower is quieter at no tracking cost. | -| --{prefix}left.elbow.firmware.speed_ki | `null` (shoulders, shoulder_3 and elbow `1e-05`) | Firmware speed-loop integral gain. Lower than the stock `1e-4` is smoother on a geared joint: the integrator winds up while the joint is stuck and dumps it at release. | +| --{prefix}left.elbow.firmware.position_kd | `null` (shoulders and elbow `0.1`, shoulder_3 and wrist_1 `0.5`) | Firmware position-loop derivative gain (protocol V4.2+). The motor stores it, but the 0xA4 loop on the X8-P20 measured inert to it. | +| --{prefix}left.elbow.firmware.speed_kp | `null` (shoulders `0.07`, shoulder_3, wrist_1 and elbow `0.05`) | Firmware speed-loop proportional gain. Not a damper in practice — it did nothing for the loop's 5 Hz reversal mode — but the knob on the speed loop's own resonance (~100 Hz on the X8-P20, ~135 Hz on the X6-P20): the current tone grows with it until the loop goes unstable (0.2 on the shoulder, 0.1 at position_kp 1.5 on the elbow). Lower is quieter at no tracking cost. | +| --{prefix}left.elbow.firmware.speed_ki | `null` (every MyActuator joint `1e-05`) | Firmware speed-loop integral gain. Lower than the stock `1e-4` is smoother on a geared joint: the integrator winds up while the joint is stuck and dumps it at release. | Per-arm and per-joint flags are shown for `left` / `elbow`. Swap in `right`, or any joint: `shoulder_1`, `shoulder_2`, `shoulder_3`, `elbow`, `wrist_1`, `wrist_2`, `wrist_3`. diff --git a/tests/test_firmware_gains.py b/tests/test_firmware_gains.py index 79e6cc55..0d8906ea 100644 --- a/tests/test_firmware_gains.py +++ b/tests/test_firmware_gains.py @@ -40,22 +40,23 @@ def test_elbow_carries_its_own_set_on_both_arms(self) -> None: }, ) - def test_shoulder_3_carries_its_own_set_on_both_arms(self) -> None: + def test_x6_roll_joints_share_a_set_on_both_arms(self) -> None: cfg = AxolConfig() for arm in (cfg.left, cfg.right): - self.assertEqual( - arm.shoulder_3.firmware.as_dict(), - { - "position_kp": 1.0, - "position_kd": 0.5, - "speed_kp": 0.05, - "speed_ki": 1e-5, - }, - ) - - def test_other_joints_leave_the_motor_alone(self) -> None: + for joint in (arm.shoulder_3, arm.wrist_1): + self.assertEqual( + joint.firmware.as_dict(), + { + "position_kp": 1.0, + "position_kd": 0.5, + "speed_kp": 0.05, + "speed_ki": 1e-5, + }, + ) + + def test_damiao_joints_leave_the_motor_alone(self) -> None: arm = AxolConfig().left - for name in ("wrist_1", "wrist_2", "wrist_3"): + for name in ("wrist_2", "wrist_3"): self.assertEqual(getattr(arm, name).firmware.as_dict(), {}) def test_defaults_survive_the_stiffness_blend(self) -> None: @@ -210,12 +211,13 @@ async def test_cold_configured_joints_get_their_gains_and_others_are_untouched( self.assertAlmostEqual(motor.store[_MA_PID_IDX["speed_ki"]], 1e-5, 9) # position_kd 0.1 is already the stock value: read, never written. self.assertNotIn(_MA_PID_IDX["position_kd"], [i for i, _ in motor.writes]) - # wrist_1 has no firmware block configured. - self.assertEqual(w1.writes, []) - self.assertEqual(sum("written to ROM" in m for m in logs.output), 3) + # wrist_1 carries the X6 roll set: its position and speed gains change too. + self.assertAlmostEqual(w1.store[_MA_PID_IDX["position_kp"]], 1.0, 6) + self.assertAlmostEqual(w1.store[_MA_PID_IDX["speed_kp"]], 0.05, 6) + self.assertEqual(sum("written to ROM" in m for m in logs.output), 4) # Every motor that took a write is rebooted so its loop loads the new # gains; the untouched one is not. - self.assertEqual([s1.resets, s2.resets, elbow.resets, w1.resets], [1, 1, 1, 0]) + self.assertEqual([s1.resets, s2.resets, elbow.resets, w1.resets], [1, 1, 1, 1]) async def test_a_provisioned_motor_is_neither_written_nor_reset(self) -> None: s1 = _FakeMotor(_stock()) From 414d231e15d4ef05e32a7723bf1488ba20e3a785 Mon Sep 17 00:00:00 2001 From: Shawn Patel Date: Tue, 22 Sep 2026 18:37:22 +0000 Subject: [PATCH 33/80] tune.a4: tune the Damiao wrists' position-velocity loop too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Damiao DM-J4310 wrists (wrist_2, wrist_3) carry the same three-loop cascade as the MyActuator joints — KP_APR / KI_APR position, KP_ASR / KI_ASR velocity, in RAM registers that take effect on write (0x55) and persist with 0xAA — behind an always-on trapezoidal profiler (ACC / DEC registers, rad/s²). Each 0x100+ID command (p_des, v_des cap, float32 LE) is answered with the feedback frame, so position comes back at 16 bits over ±PMAX (0.022°) with no paired read, and torque (Nm) fills the current channel. The wrists were found at ACC 2 rad/s² (~115 deg/s²), far too slow to follow a streamed target. The tuner now accepts them: gains read/written through the registers, `--dm-acc` for the profiler (restored afterwards unless --keep), the wave streamed on 0x100 with the reply as the sample, held Damiao joints sampled through register 0x50, and the score card labelled torque. MyActuator-only flags (--accel, kd, current gains) are refused on a Damiao joint and vice versa. The Firmware-loop tab lists the wrists and gets the ramp field. Co-Authored-By: Claude Fable 5.1 --- almond_axol/cli/tune/a4.py | 234 +++++++++++++++--- docs/cli/tune-a4.mdx | 3 +- tests/test_tune_a4.py | 26 ++ .../diagnostics/tuning-workbench.tsx | 14 +- 4 files changed, 235 insertions(+), 42 deletions(-) diff --git a/almond_axol/cli/tune/a4.py b/almond_axol/cli/tune/a4.py index c8f0c8f5..9ec873e7 100644 --- a/almond_axol/cli/tune/a4.py +++ b/almond_axol/cli/tune/a4.py @@ -61,6 +61,7 @@ from ...constants import ARM_JOINTS, Joint from ...motor import CanBus, ControlMode, Motor, MotorError +from ...motor.damiao import DamiaoMotor from ...motor.myactuator import _MA_PID_IDX, MyActuatorMotor from ...robot.axol import arm_limits from ...tuning import ( @@ -94,6 +95,20 @@ #: never finished a plan before the next target and the joint barely moved. _ACCEL_STEP_FOLLOW = 60000 +# Damiao (the wrists): the position-velocity mode is the same three-loop +# cascade with the gains in RAM registers (0x55 writes take effect at once, +# 0xAA stores them) — KP_APR / KI_APR position, KP_ASR / KI_ASR velocity — +# and its own trapezoidal profiler whose ACC / DEC registers (rad/s², DEC +# negative) shape every streamed target. Each 0x100+ID command (p_des, +# v_des cap, both float32 LE) is answered with the MIT feedback frame, so +# position comes back at 16 bits over ±PMAX (0.022° at 12.5 rad), no paired +# read needed. Register 0x50 (p_m) is the float position for held joints. +_DM_GAIN_REGS = {"speed_kp": 25, "speed_ki": 26, "position_kp": 27, "position_ki": 28} +_DM_REG_ACC = 4 +_DM_REG_DEC = 5 +_DM_REG_PM = 80 +_DM_REPLY_TIMEOUT_S = 0.02 + GAIN_NAMES: tuple[str, ...] = tuple(_MA_PID_IDX) #: A joint held on its own firmware position loop while another joint runs @@ -424,8 +439,12 @@ def _decode_a4_reply(resp: bytes) -> tuple[float, float]: return iq, speed -async def _read_gains(driver: MyActuatorMotor) -> dict[str, float]: +async def _read_gains(driver: MyActuatorMotor | DamiaoMotor) -> dict[str, float]: out: dict[str, float] = {} + if isinstance(driver, DamiaoMotor): + for name, rid in _DM_GAIN_REGS.items(): + out[name] = float(await driver._read_register(rid)) + return out for name, index in _MA_PID_IDX.items(): resp = await driver._request(bytes([_MA_READ_GAIN, index, 0, 0, 0, 0, 0, 0])) out[name] = float(struct.unpack_from(" dict[str, float]: async def _write_gains( - driver: MyActuatorMotor, gains: dict[str, float], persist: bool + driver: MyActuatorMotor | DamiaoMotor, gains: dict[str, float], persist: bool ) -> None: + if isinstance(driver, DamiaoMotor): + # RAM registers take effect immediately; 0xAA stores them all. + for name, value in gains.items(): + await driver._write_register(_DM_GAIN_REGS[name], float(value)) + await asyncio.sleep(0.02) + if persist: + await driver._store_parameters() + await asyncio.sleep(_FLASH_SETTLE_S) + return cmd = _MA_WRITE_GAIN_ROM if persist else _MA_WRITE_GAIN_RAM for name, value in gains.items(): await driver._request( @@ -443,6 +471,31 @@ async def _write_gains( await asyncio.sleep(_FLASH_SETTLE_S if persist else 0.02) +def dm_frame(position_rad: float, cap_dps: float) -> bytes: + """The Damiao position-velocity command: ``(p_des rad, v_des rad/s)`` LE.""" + return struct.pack(" tuple[float, float]: + return ( + float(await driver._read_register(_DM_REG_ACC)), + float(await driver._read_register(_DM_REG_DEC)), + ) + + +async def _dm_write_ramps( + driver: DamiaoMotor, acc: float, dec: float, persist: bool +) -> tuple[float, float]: + await driver._write_register(_DM_REG_ACC, float(acc)) + await asyncio.sleep(0.02) + await driver._write_register(_DM_REG_DEC, float(dec)) + await asyncio.sleep(0.02) + if persist: + await driver._store_parameters() + await asyncio.sleep(_FLASH_SETTLE_S) + return await _dm_read_ramps(driver) + + async def _read_accel(driver: MyActuatorMotor) -> tuple[int, int]: out = [] for kind in (0x00, 0x01): @@ -462,7 +515,7 @@ async def _write_accel(driver: MyActuatorMotor, acc: int, dec: int) -> tuple[int async def _stream( motor: JointFrameMotor, - driver: MyActuatorMotor, + driver: MyActuatorMotor | DamiaoMotor, samples: list[tuple[float, float, float]], cap_dps: float, rate: float, @@ -482,32 +535,56 @@ async def _stream( held_items = [ (j.value, jm.motor._driver, jm.offset) for j, jm in (held or {}).items() - if isinstance(jm.motor._driver, MyActuatorMotor) + if isinstance(jm.motor._driver, (MyActuatorMotor, DamiaoMotor)) ] held_log: dict[str, list[tuple[float, float]]] = {n: [] for n, _, _ in held_items} + is_dm = isinstance(driver, DamiaoMotor) + loop = asyncio.get_running_loop() t0 = time.perf_counter() deadline = t0 for k, (_t_nominal, target, v_cmd) in enumerate(samples): deadline += period cap = speed_cap(v_cmd, cap_dps, cap_track, cap_floor_dps) - resp = await driver._request(_a4_frame(target - offset, cap)) - iq, speed = _decode_a4_reply(resp) - fine = await driver._request(bytes([_MA_MULTI_TURN_ANGLE, 0, 0, 0, 0, 0, 0, 0])) - pos = struct.unpack_from(" None: # type: ignore[ p.add_argument( "--cap", type=float, default=60.0, help="0xA4 speed cap, deg/s (default: 60)" ) + p.add_argument( + "--dm-acc", + type=float, + default=None, + help="Damiao wrists only: the position-velocity profiler's ACC (and -DEC), rad/s², " + "written to the registers for the run and restored afterwards unless --keep " + "(--persist stores them). The wrists were found at 2 rad/s² (~115 deg/s²), far " + "too slow to follow a streamed target.", + ) p.add_argument( "--pose", action="append", @@ -734,15 +825,36 @@ async def _run(args: argparse.Namespace) -> None: raw = {j: Motor(bus, j) for j in ARM_JOINTS} await asyncio.gather(*[m.enable() for m in raw.values()]) driver = raw[joint]._driver - if not isinstance(driver, MyActuatorMotor): - raise SystemExit(f"{joint.value} is not a MyActuator joint") + if not isinstance(driver, (MyActuatorMotor, DamiaoMotor)): + raise SystemExit(f"{joint.value} has no firmware position loop to tune") + is_dm = isinstance(driver, DamiaoMotor) + if is_dm: + unsupported = sorted(set(requested) - set(_DM_GAIN_REGS)) + if unsupported: + raise SystemExit( + f"{joint.value} is a Damiao motor: its loop has " + f"{', '.join(_DM_GAIN_REGS)} only (not {', '.join(unsupported)})" + ) + if args.accel is not None: + raise SystemExit( + f"{joint.value} is a Damiao motor: its profiler is ACC/DEC in rad/s² — " + "use --dm-acc, not --accel" + ) + elif args.dm_acc is not None: + raise SystemExit( + f"--dm-acc is for the Damiao wrists; {joint.value} takes --accel" + ) before_gains: dict[str, float] | None = None before_accel: tuple[int, int] | None = None + before_ramps: tuple[float, float] | None = None log: list[dict] = [] reason: str | None = None used_gains: dict[str, float] = {} accel_used: tuple[int, int] | None = None + ramps_used: tuple[float, float] | None = None held_scores: dict[str, dict[str, float]] = {} + current_label = "torque" if is_dm else "current" + current_unit = "Nm" if is_dm else "A" # Planner acceleration goes in *before* the mode switch below: that # switch is a 0x76 reset, and the reset is what makes a planner value @@ -753,28 +865,52 @@ async def _run(args: argparse.Namespace) -> None: # tracking. Non-zero values do apply live on that firmware (5000 → # 60000 took effect mid-session); the X8-P20 shoulders (2026042402) # apply 0 live as well. Writing first is right for every one of them. - stored_accel = await _read_accel(driver) - print( - f" planner accel/decel stored: {stored_accel[0]}/{stored_accel[1]} dps/s" - ) - if args.accel is not None and stored_accel != (args.accel, args.accel): - before_accel = stored_accel - accel_used = await _write_accel(driver, args.accel, args.accel) + cap_track = args.cap_track + if is_dm: + # Damiao: the profiler is always on (ACC in (0, fmax), DEC < 0), + # registers in RAM, no reset needed for them to take effect. A + # stored 2 rad/s² (the wrists as found) is ~115 deg/s² — far too + # slow to follow a streamed target; the sweep says what does. + stored_ramps = await _dm_read_ramps(driver) print( - f" planner accel/decel {stored_accel[0]}/{stored_accel[1]} → {accel_used[0]}/{accel_used[1]} dps/s" + f" profiler ACC/DEC stored: {stored_ramps[0]:g}/{stored_ramps[1]:g} rad/s²" ) + if args.dm_acc is not None and (stored_ramps[0], -stored_ramps[1]) != ( + args.dm_acc, + args.dm_acc, + ): + before_ramps = stored_ramps + ramps_used = await _dm_write_ramps( + driver, args.dm_acc, -abs(args.dm_acc), args.persist + ) + print( + f" profiler ACC/DEC {stored_ramps[0]:g}/{stored_ramps[1]:g} → " + f"{ramps_used[0]:g}/{ramps_used[1]:g} rad/s²" + ) + else: + ramps_used = stored_ramps else: - accel_used = stored_accel - if accel_used[0] not in (0, _ACCEL_STEP_FOLLOW): + stored_accel = await _read_accel(driver) print( - f" ! planner acceleration is {accel_used[0]} dps/s: the firmware re-plans " - "every streamed target and will not follow the wave — pass --accel 0 " - f"(direct PI tracking) or --accel {_ACCEL_STEP_FOLLOW} (planner completes " - "each step within the tick)" + f" planner accel/decel stored: {stored_accel[0]}/{stored_accel[1]} dps/s" ) + if args.accel is not None and stored_accel != (args.accel, args.accel): + before_accel = stored_accel + accel_used = await _write_accel(driver, args.accel, args.accel) + print( + f" planner accel/decel {stored_accel[0]}/{stored_accel[1]} → {accel_used[0]}/{accel_used[1]} dps/s" + ) + else: + accel_used = stored_accel + if accel_used[0] not in (0, _ACCEL_STEP_FOLLOW): + print( + f" ! planner acceleration is {accel_used[0]} dps/s: the firmware re-plans " + "every streamed target and will not follow the wave — pass --accel 0 " + f"(direct PI tracking) or --accel {_ACCEL_STEP_FOLLOW} (planner completes " + "each step within the tick)" + ) - cap_track = args.cap_track - if accel_used[0] == 0 and cap_track > 0: + if accel_used is not None and accel_used[0] == 0 and cap_track > 0: # Under direct tracking the cap is a hard limit on the PI output: # pinned near the commanded speed the loop can never catch up # (right elbow, pKp 0.5, cap-track 1.1: 1.8° RMS, 480 ms lag). @@ -941,6 +1077,21 @@ async def _run(args: argparse.Namespace) -> None: print(" previous gains restored") elif before_gains is not None: print(" gains kept (--keep)") + if ( + before_ramps is not None + and not args.keep + and isinstance(driver, DamiaoMotor) + ): + got = await _dm_write_ramps( + driver, before_ramps[0], before_ramps[1], args.persist + ) + print( + f" profiler ACC/DEC restored to {got[0]:g}/{got[1]:g} rad/s²" + ) + elif before_ramps is not None and ramps_used is not None: + print( + f" profiler left at {ramps_used[0]:g}/{ramps_used[1]:g} rad/s² (--keep)" + ) if before_accel is not None and not args.keep: got = await _write_accel(driver, before_accel[0], before_accel[1]) print(f" planner accel/decel restored to {got[0]}/{got[1]} dps/s") @@ -972,9 +1123,10 @@ async def _run(args: argparse.Namespace) -> None: f" velocity ripple {metrics['v_ripple']:.2f} (MIT stick-slip ≈ 0.8, smooth < 0.2) stuck windows {metrics['stuck_frac']:.2f}" ) print( - f" current RMS {metrics['iq_rms']:.2f} A peak {metrics['iq_max']:.2f} A " - f"spread {metrics['iq_sd']:.2f} A 3-8 Hz mode {metrics['iq_mode']:.2f} A " - f"loop {metrics['hz']:.0f} Hz" + f" {current_label} RMS {metrics['iq_rms']:.2f} {current_unit} " + f"peak {metrics['iq_max']:.2f} {current_unit} " + f"spread {metrics['iq_sd']:.2f} {current_unit} " + f"3-8 Hz mode {metrics['iq_mode']:.2f} {current_unit} loop {metrics['hz']:.0f} Hz" ) print(f"{'─' * 66}") if args.save_run: @@ -991,6 +1143,8 @@ async def _run(args: argparse.Namespace) -> None: "cap_track": cap_track, "cap_floor_dps": args.cap_floor, "accel": list(accel_used) if accel_used else None, + "vendor": "damiao" if is_dm else "myactuator", + "dm_acc": list(ramps_used) if ramps_used else None, "persist": args.persist, "pose": args.pose or None, } diff --git a/docs/cli/tune-a4.mdx b/docs/cli/tune-a4.mdx index dca0fc5a..bcffb5fb 100644 --- a/docs/cli/tune-a4.mdx +++ b/docs/cli/tune-a4.mdx @@ -19,7 +19,7 @@ Safety, built in: |---|---| | `--l` / `--r` | Arm side (required) | | `--channel IFACE` | SocketCAN interface override | -| `--joint JOINT` | MyActuator joint: `shoulder_1`, `shoulder_2`, `shoulder_3`, `elbow`, `wrist_1` (required) | +| `--joint JOINT` | Any arm joint with a firmware position loop: the MyActuator `shoulder_1`, `shoulder_2`, `shoulder_3`, `elbow`, `wrist_1` on 0xA4, or the Damiao `wrist_2`, `wrist_3` on their position-velocity mode (required) | | `--mode sine\|triangle` | Wave shape (default: `triangle`) | | `--center DEG` | Centre, joint-frame degrees (default: midpoint of the safe range) | | `--amp DEG` | Half-travel, degrees (default: 10) | @@ -28,6 +28,7 @@ Safety, built in: | `--duration S` | Seconds of wave (default: 12) | | `--rate HZ` | Command rate (default: 400, the rate the realtime core streams a4 joints at; tune at the rate you will run — the step size is the excitation, and 200 Hz put an audible staircase on the shoulder at 12 deg/s that 400 removed) | | `--cap DPS` | 0xA4 speed cap (default: 60) | +| `--dm-acc RAD_S2` | Damiao wrists (`wrist_2`, `wrist_3`) only: the position-velocity profiler's ACC and −DEC registers for the run, restored afterwards unless `--keep` (`--persist` stores them). The Damiao loop is the same cascade with its gains in RAM registers (`position_kp`/`position_ki` = KP_APR/KI_APR, `speed_kp`/`speed_ki` = KP_ASR/KI_ASR, no kd or current gains) behind an always-on trapezoidal profiler; each 0x100 command answers with the feedback frame, so position is 16-bit and the "current" columns carry torque in Nm. The wrists were found at 2 rad/s² (~115 °/s²) | | `--pose JOINT=DEG` | Hold another joint at this joint-frame angle during the run, repeatable, overriding the sweep's own clearance pose for that joint (same rules as [`tune.pid`](/cli/tune-pid): inside the arm's limits, shoulder_2 outboard only). A firmware loop that is well damped with the arm hanging can oscillate with it extended — right shoulder_2 did, held 10° outboard with shoulder_1 raised and the elbow bent during a shoulder_3 sweep — so tune the worst-case pose too. Every held joint is sampled round-robin during the wave (one 0x92 read per tick) and scored: drift from its hold, peak-to-peak, std and dominant frequency, printed and saved under `metrics.held` | | `--cap-track K` | Per-command cap = `K × |commanded speed|`, floored at `--cap-floor`, never above `--cap` (default: 0 = fixed cap). With `--accel 60000` a fixed cap lets the planner burst through each 200 Hz step at the cap and idle the rest of the tick — 4× the current spread on the elbow, 68–82 Hz velocity content; `1.1`–`1.2` keeps the joint moving continuously at about the commanded speed. Ignored (with a warning) when the planner is at 0: under direct tracking the cap is a hard limit on the PI output and would only throttle the loop | | `--cap-floor DPS` | Lowest cap `--cap-track` may set, so a stationary or reversing target still corrects (default: 1) | diff --git a/tests/test_tune_a4.py b/tests/test_tune_a4.py index 811cfdc1..6de5a933 100644 --- a/tests/test_tune_a4.py +++ b/tests/test_tune_a4.py @@ -229,3 +229,29 @@ def test_held_summary_scores_drift_and_oscillation(self) -> None: self.assertAlmostEqual(out["shoulder_2"]["drift"], 0.0, delta=0.05) self.assertAlmostEqual(out["elbow"]["drift"], -6.0, places=6) self.assertEqual(out["elbow"]["std"], 0.0) + + +class DamiaoPathTest(unittest.TestCase): + def test_dm_frame_is_two_little_endian_floats_in_rad_units(self) -> None: + import struct + + frame = a4.dm_frame(1.25, 60.0) + self.assertEqual(len(frame), 8) + p, v = struct.unpack(" None: + # DM-J4310 register map: 0x19 KP_ASR, 0x1A KI_ASR, 0x1B KP_APR, 0x1C KI_APR. + self.assertEqual( + a4._DM_GAIN_REGS, + { + "speed_kp": 0x19, + "speed_ki": 0x1A, + "position_kp": 0x1B, + "position_ki": 0x1C, + }, + ) + self.assertEqual((a4._DM_REG_ACC, a4._DM_REG_DEC, a4._DM_REG_PM), (4, 5, 0x50)) diff --git a/web/app/src/components/diagnostics/tuning-workbench.tsx b/web/app/src/components/diagnostics/tuning-workbench.tsx index b7dd674e..982ec00f 100644 --- a/web/app/src/components/diagnostics/tuning-workbench.tsx +++ b/web/app/src/components/diagnostics/tuning-workbench.tsx @@ -353,7 +353,7 @@ const TABS: WbTab[] = [ key: "joint", label: "joint", type: "select", - options: ["shoulder_1", "shoulder_2", "shoulder_3", "elbow", "wrist_1"], + options: ARM_JOINT_OPTIONS, }, { key: "mode", label: "wave", type: "select", options: ["triangle", "sine"] }, { @@ -398,6 +398,16 @@ const TABS: WbTab[] = [ placeholder: "1", hint: "lowest cap the tracking cap may set, so a stationary target still corrects", }, + { + key: "dm_acc", + label: "Damiao ACC/DEC (rad/s²)", + type: "number", + placeholder: "stored", + hint: + "wrist_2 / wrist_3 only: the position-velocity profiler's acceleration (and " + + "-deceleration), written to the registers for the run and restored afterwards " + + "unless kept. Found at 2 rad/s² (~115 °/s²), far too slow to follow a stream", + }, { key: "accel", label: "planner accel (dps/s)", @@ -1164,6 +1174,8 @@ function runFormValues(meta: TuningRunMeta): Record | null { if (typeof p.cap_track === "number" && p.cap_track > 0) put("cap_track", p.cap_track) if (typeof p.cap_track === "number" && p.cap_track > 0) put("cap_floor", p.cap_floor_dps) if (Array.isArray(p.accel) && typeof p.accel[0] === "number") out["accel"] = String(p.accel[0]) + if (Array.isArray(p.dm_acc) && typeof p.dm_acc[0] === "number") + out["dm_acc"] = String(p.dm_acc[0]) if (Array.isArray(p.pose) && p.pose.length > 0) out["pose"] = p.pose.join(" ") for (const k of ["position_kp", "position_ki", "position_kd", "speed_kp", "speed_ki"]) { const v = g[k] From 9709ae5db3623a0680aa6eb29c311a1bb9f0f46a Mon Sep 17 00:00:00 2001 From: Shawn Patel Date: Tue, 22 Sep 2026 18:51:23 +0000 Subject: [PATCH 34/80] =?UTF-8?q?Tuners:=20correct=20the=20=C2=B1360=C2=B0?= =?UTF-8?q?=20boot=20wrap=20on=20every=20read;=20never=20ramp=20from=20an?= =?UTF-8?q?=20implausible=20reading?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Right elbow, 2026-09-22, during a wrist_3 tune.a4 run: the mode-switch reset every tuner issues re-derived the elbow's multi-turn angle a full turn off (−213.7° for a joint at 146.3° — its rest sits 34° from the ±180° boundary), the fixed motor→joint offset turned that into −363.7°, and the homing ramp, which commanded before it read, drove the motor a full turn into its hard stop. The stall protection tripped at 40 Nm and 55 °C; the tuner then reported "never reached target" and left the arm holding. The production bring-up already handles this (fixed_stop_wrap_correction); the tuners' JointFrameMotor did not. It now re-derives the wrap on every get_position() and folds it into frame_offset, which every command uses; joint_frame_motors() reads each joint once at construction so an unset zero is refused up front. _ramp_verified reads before it commands and refuses any reading more than 15° outside the arm's limits. tune.a4's streams use frame_offset. Co-Authored-By: Claude Fable 5.1 --- almond_axol/cli/tune/a4.py | 8 +-- almond_axol/cli/tune/friction.py | 27 +++++++++ almond_axol/tuning/joint_frame.py | 61 +++++++++++++++++--- tests/test_joint_frame_wrap.py | 95 +++++++++++++++++++++++++++++++ 4 files changed, 178 insertions(+), 13 deletions(-) create mode 100644 tests/test_joint_frame_wrap.py diff --git a/almond_axol/cli/tune/a4.py b/almond_axol/cli/tune/a4.py index 9ec873e7..7c4cd07b 100644 --- a/almond_axol/cli/tune/a4.py +++ b/almond_axol/cli/tune/a4.py @@ -529,11 +529,11 @@ async def _stream( held joints' ``{joint: [(t, position_rad), ...]}`` sampled round-robin — one 0x92 read of one held joint per tick, so each is seen at ``rate / len(held)`` Hz and the wave's own two round trips stay first.""" - offset = motor.offset + offset = motor.frame_offset period = 1.0 / rate log: list[dict] = [] held_items = [ - (j.value, jm.motor._driver, jm.offset) + (j.value, jm.motor._driver, jm.frame_offset) for j, jm in (held or {}).items() if isinstance(jm.motor._driver, (MyActuatorMotor, DamiaoMotor)) ] @@ -621,10 +621,10 @@ async def _hold( while time.perf_counter() < end: if isinstance(driver, DamiaoMotor): await driver._raw_send( - dm_frame(pose - motor.offset, cap_dps), 0x100 + driver._motor_id + dm_frame(pose - motor.frame_offset, cap_dps), 0x100 + driver._motor_id ) else: - await driver._request(_a4_frame(pose - motor.offset, cap_dps)) + await driver._request(_a4_frame(pose - motor.frame_offset, cap_dps)) await asyncio.sleep(period) diff --git a/almond_axol/cli/tune/friction.py b/almond_axol/cli/tune/friction.py index 8e06f141..b5eaf46b 100644 --- a/almond_axol/cli/tune/friction.py +++ b/almond_axol/cli/tune/friction.py @@ -131,6 +131,30 @@ async def _ramp_verified( joints = list(targets) if not joints: return + # Read before commanding. The read re-derives each fixed-stop joint's + # ±360° boot wrap (see JointFrameMotor) — the MyActuator reset that + # precedes every ramp can leave a reading a full turn off, and a command + # against it drives the motor a full turn (right elbow into its hard + # stop at 40 Nm, 2026-09-22). Then refuse anything still implausible. + pre = await asyncio.gather(*[motors[j].get_position() for j in joints]) + bad = [] + for j, pos in zip(joints, pre): + is_left = getattr(motors[j], "_is_left", None) + if is_left is None: + continue + lo, hi = arm_limits(j, is_left) + if not (lo - _RAMP_SANITY_SLACK <= pos <= hi + _RAMP_SANITY_SLACK): + bad.append( + f"{j.value} reads {math.degrees(pos):+.1f}° (limits " + f"[{math.degrees(lo):+.0f}, {math.degrees(hi):+.0f}]°)" + ) + if bad: + raise RuntimeError( + "refusing to ramp — implausible joint reading(s): " + + ", ".join(bad) + + " — a multi-turn wrap or an unset zero; power-cycle or reset " + "the motor and re-run `axol motor.set-zero-pos --guided` if it persists" + ) positions: list[float] = [] for _attempt in range(2): await asyncio.gather( @@ -165,6 +189,9 @@ async def _ramp_verified( #: A joint this far (rad) from its rest pose still carries gravity load; the #: tuners refuse to reset/disable it and leave it holding instead. _REST_TOL = math.radians(5.0) +#: A joint reading this far outside its arm limits is not a position, it is +#: a wrapped multi-turn count or an unset zero — never ramp from it. +_RAMP_SANITY_SLACK = math.radians(15.0) async def _safe_torque_off( diff --git a/almond_axol/tuning/joint_frame.py b/almond_axol/tuning/joint_frame.py index 42578bbc..1a7442e9 100644 --- a/almond_axol/tuning/joint_frame.py +++ b/almond_axol/tuning/joint_frame.py @@ -25,15 +25,16 @@ from ..motor import ControlMode, Joint, Motor from ..motor.damiao import DamiaoMotor from ..motor.myactuator import ( - MyActuatorMotor, _MA_KD_MAX, _MA_KP_MAX, _MA_V_MAX, + MyActuatorMotor, ) from ..robot.axol import ( EITHER_STOP_JOINTS, closer_end_stop, end_stop_offset_from_position, + fixed_stop_wrap_correction, ) @@ -43,20 +44,52 @@ class JointFrameMotor: Construct via :func:`joint_frame_motors`, which resolves the per-joint motor→joint offset. Only the calls the tuners use are exposed; add passthroughs as needed. + + **Boot wrap.** A fixed-stop joint's multi-turn reading can come back + exactly ±360° off after any MyActuator 0x76 reset — which every tuner + issues through ``set_control_mode`` — because the motor re-derives it + within ±180° of the single-turn zero, and the right elbow at rest sits + 34° from that boundary. Commanding the joint frame through a fixed + offset against a wrapped reading sends the motor a full turn: right + elbow, 2026-09-22, into its hard stop at 40 Nm until its stall + protection tripped. Every :meth:`get_position` therefore re-derives the + wrap with :func:`fixed_stop_wrap_correction` (the production bring-up's + check) and folds it into :attr:`frame_offset`, which every command uses. + Read before you command — :func:`~almond_axol.cli.tune.friction._ramp_verified` + does — and a wrapped reading is corrected instead of chased. """ - def __init__(self, motor: Motor, offset: float) -> None: + def __init__( + self, motor: Motor, offset: float, is_left: bool | None = None + ) -> None: self.motor = motor self.offset = offset + self._is_left = is_left + #: ±2π correction the last read said the motor frame needs (0 if none). + self.wrap = 0.0 @property def joint(self) -> Joint: return self.motor.joint + @property + def frame_offset(self) -> float: + """motor→joint offset including the current boot-wrap correction.""" + return self.offset + self.wrap + + def _refresh_wrap(self, motor_pos: float) -> None: + if ( + self._is_left is None + or self.joint in EITHER_STOP_JOINTS + or self.joint == Joint.GRIPPER + ): + return + self.wrap = fixed_stop_wrap_correction(self.joint, self._is_left, motor_pos) + @property def position(self) -> float: """Latest cached position (rad, joint frame).""" - return self.motor.position + self.offset + return self.motor.position + self.frame_offset @property def torque(self) -> float: @@ -69,18 +102,24 @@ def feedback_ts(self) -> float: return self.motor.feedback_ts async def get_position(self) -> float: - """Current position (rad, joint frame).""" - return await self.motor.get_position() + self.offset + """Current position (rad, joint frame), re-deriving the boot wrap. + + Raises ``MotorError`` (from :func:`fixed_stop_wrap_correction`) when + the reading fits no plausible band — the zero is unset or stale. + """ + motor_pos = await self.motor.get_position() + self._refresh_wrap(motor_pos) + return motor_pos + self.frame_offset async def set_impedance( self, p_des: float, v_des: float, kp: float, kd: float, t_ff: float ) -> None: """Impedance command with ``p_des`` in the joint frame.""" - await self.motor.set_impedance(p_des - self.offset, v_des, kp, kd, t_ff) + await self.motor.set_impedance(p_des - self.frame_offset, v_des, kp, kd, t_ff) async def set_position_velocity(self, position: float, max_speed: float) -> None: """Position-velocity command with ``position`` in the joint frame.""" - await self.motor.set_position_velocity(position - self.offset, max_speed) + await self.motor.set_position_velocity(position - self.frame_offset, max_speed) async def set_control_mode(self, mode: ControlMode) -> None: await self.motor.set_control_mode(mode) @@ -125,7 +164,7 @@ async def run_experiment( motor_id=driver._motor_id, differentiate=differentiate, rate_hz=rate_hz, - offset=self.offset, + offset=self.frame_offset, kp=kp, kd=kd, ranges=ranges, @@ -155,5 +194,9 @@ async def joint_frame_motors( ) else: offset = closer_end_stop(j, is_left)[0] - wrapped[j] = JointFrameMotor(m, offset) + jm = JointFrameMotor(m, offset, is_left) + # Read once now: derives the boot wrap (and refuses an unset zero) + # before any tuner commands the joint. + await jm.get_position() + wrapped[j] = jm return wrapped diff --git a/tests/test_joint_frame_wrap.py b/tests/test_joint_frame_wrap.py new file mode 100644 index 00000000..ba81ed37 --- /dev/null +++ b/tests/test_joint_frame_wrap.py @@ -0,0 +1,95 @@ +"""The tuners' joint frame survives a ±360° multi-turn boot wrap. + +Right elbow, 2026-09-22: after the tuner's mode-switch reset the motor +re-derived its multi-turn angle a full turn off (−213.7° for a joint at +146.3°), the fixed motor→joint offset turned that into −363.7° in the joint +frame, and the homing ramp drove the motor a full turn into its hard stop at +40 Nm until the stall protection tripped. The proxy now re-derives the wrap +on every read and the ramp refuses an implausible reading before commanding. +""" + +from __future__ import annotations + +import math +import unittest +from types import SimpleNamespace + +from almond_axol.cli.tune.friction import _ramp_verified +from almond_axol.constants import Joint +from almond_axol.robot.axol import closer_end_stop +from almond_axol.tuning.joint_frame import JointFrameMotor + + +class _FakeMotor: + def __init__(self, joint: Joint, motor_pos_deg: float) -> None: + self.joint = joint + self.position = math.radians(motor_pos_deg) + self.commands: list[float] = [] + + async def get_position(self) -> float: + return self.position + + async def set_position_velocity(self, position: float, max_speed: float) -> None: + self.commands.append(position) + + +class WrapAwareJointFrameTest(unittest.IsolatedAsyncioTestCase): + async def test_wrapped_reading_is_corrected_and_commands_use_the_correction( + self, + ) -> None: + offset = closer_end_stop(Joint.ELBOW, False)[0] + # The motor really is at 146.3° but re-derived its angle as −213.7°. + fake = _FakeMotor(Joint.ELBOW, -213.66) + jm = JointFrameMotor(fake, offset, is_left=False) + q = await jm.get_position() + self.assertAlmostEqual(jm.wrap, math.tau, places=9) + self.assertAlmostEqual(math.degrees(q), 146.34 + math.degrees(offset), places=1) + self.assertLess(abs(math.degrees(q)), 10.0) # a few degrees off rest, not −363 + # Commanding rest sends a motor-frame target next to the wrapped + # reading, not a full turn away from it. + await jm.set_position_velocity(0.0, 0.5) + self.assertAlmostEqual( + math.degrees(fake.commands[-1] - fake.position), -math.degrees(q), places=1 + ) + self.assertLess(abs(math.degrees(fake.commands[-1] - fake.position)), 10.0) + + async def test_unwrapped_reading_needs_no_correction(self) -> None: + offset = closer_end_stop(Joint.ELBOW, False)[0] + fake = _FakeMotor(Joint.ELBOW, 146.3) + jm = JointFrameMotor(fake, offset, is_left=False) + q = await jm.get_position() + self.assertEqual(jm.wrap, 0.0) + self.assertLess(abs(math.degrees(q)), 10.0) + self.assertAlmostEqual(jm.frame_offset, offset) + + async def test_proxy_without_side_keeps_the_legacy_fixed_offset(self) -> None: + fake = _FakeMotor(Joint.ELBOW, -213.66) + jm = JointFrameMotor(fake, 0.5) + self.assertAlmostEqual(await jm.get_position(), fake.position + 0.5) + self.assertEqual(jm.wrap, 0.0) + + +class RampRefusesImplausibleReadingsTest(unittest.IsolatedAsyncioTestCase): + async def test_ramp_reads_first_and_refuses_a_reading_outside_the_limits( + self, + ) -> None: + sent: list[float] = [] + + async def get_position() -> float: + return math.radians(-363.7) + + async def set_position_velocity(position: float, max_speed: float) -> None: + sent.append(position) + + elbow = SimpleNamespace( + _is_left=False, + get_position=get_position, + set_position_velocity=set_position_velocity, + ) + with self.assertRaisesRegex(RuntimeError, "implausible"): + await _ramp_verified({Joint.ELBOW: elbow}, {Joint.ELBOW: 0.0}) + self.assertEqual(sent, []) + + +if __name__ == "__main__": + unittest.main() From be81f424903901bee16856d27a2b337d1e9c8482 Mon Sep 17 00:00:00 2001 From: Shawn Patel Date: Tue, 22 Sep 2026 19:02:53 +0000 Subject: [PATCH 35/80] Tuners: park a joint whose rest pose is a hard stop 2 deg inside it Held on its firmware position loop *at* the stop, the loop leans on the stop, the motor's stall protection cuts its output and the joint hangs limp: the right elbow (limits -150..0, rest 0 = the stop) sagged 2-6 deg and swung with whatever else moved during every wrist run on 2026-09-22, while posed at -75 it held to 0.004 deg. rest_target() parks such a joint 2 deg inside; everything else still homes to 0. Co-Authored-By: Claude Fable 5.1 --- almond_axol/cli/tune/friction.py | 32 ++++++++++++++++++++++++++++---- tests/test_sweep_safety.py | 12 ++++++++++++ 2 files changed, 40 insertions(+), 4 deletions(-) diff --git a/almond_axol/cli/tune/friction.py b/almond_axol/cli/tune/friction.py index b5eaf46b..56f2200d 100644 --- a/almond_axol/cli/tune/friction.py +++ b/almond_axol/cli/tune/friction.py @@ -237,18 +237,42 @@ async def _safe_torque_off( return True +#: How far inside its range a joint is parked when its rest pose (0) sits on +#: a hard stop. Held on a firmware position loop *at* the stop, the loop +#: leans on the stop, the motor's stall protection cuts its output and the +#: joint hangs limp: the right elbow (limits −150..0) sagged 2–6° and swung +#: with whatever else was moving during every wrist run (2026-09-22). Two +#: degrees inside is still within ``_REST_TOL`` of rest for the torque-off. +_STOP_STANDOFF = math.radians(2.0) + + +def rest_target(joint: Joint, is_left: bool | None) -> float: + """The hold target for a homed joint: 0, or 2° inside a limit that is 0.""" + if is_left is None: + return 0.0 + lo, hi = arm_limits(joint, is_left) + if abs(hi) < _STOP_STANDOFF: + return hi - _STOP_STANDOFF + if abs(lo) < _STOP_STANDOFF: + return lo + _STOP_STANDOFF + return 0.0 + + async def _home_all( motors: dict[Joint, JointFrameMotor], exclude: Joint | None = None ) -> None: - """Ramp every joint to 0 (the rest pose), one at a time in ``_HOME_ORDER``. + """Ramp every joint to rest, one at a time in ``_HOME_ORDER``. - Joints already at rest verify in one poll, so a mostly-homed arm costs - a fraction of a second per joint. + Rest is 0, except a joint whose 0 is a hard stop, which parks 2° inside + (see :func:`rest_target`). Joints already at rest verify in one poll, so + a mostly-homed arm costs a fraction of a second per joint. """ for j in _HOME_ORDER: if j == exclude or j not in motors: continue - await _ramp_verified(motors, {j: 0.0}) + await _ramp_verified( + motors, {j: rest_target(j, getattr(motors[j], "_is_left", None))} + ) async def _run_sweep_raw( diff --git a/tests/test_sweep_safety.py b/tests/test_sweep_safety.py index 48e569c4..8f744b54 100644 --- a/tests/test_sweep_safety.py +++ b/tests/test_sweep_safety.py @@ -12,6 +12,7 @@ import math import unittest +from almond_axol.cli.tune.friction import rest_target from almond_axol.constants import ARM_JOINTS, Joint from almond_axol.robot.axol import arm_limits from almond_axol.tuning.runner import probe_clearance_targets, sweep_safety @@ -44,5 +45,16 @@ def test_shoulder_3_sweep_raises_shoulder_1_mirrored(self) -> None: self.assertTrue(any("-90°" in n for n in notes)) +class RestTargetTest(unittest.TestCase): + def test_joints_whose_rest_is_a_hard_stop_park_two_degrees_inside(self) -> None: + # Right elbow: limits −150..0, so 0 is the stop; the left elbow mirrors. + self.assertAlmostEqual(math.degrees(rest_target(Joint.ELBOW, False)), -2.0) + self.assertAlmostEqual(math.degrees(rest_target(Joint.ELBOW, True)), 2.0) + # Everything else rests at 0. + for j in (Joint.SHOULDER_1, Joint.SHOULDER_2, Joint.SHOULDER_3, Joint.WRIST_1): + self.assertEqual(rest_target(j, False), 0.0) + self.assertEqual(rest_target(Joint.ELBOW, None), 0.0) + + if __name__ == "__main__": unittest.main() From ccc424e774d2a61f310e013cb29edc124cd3536a Mon Sep 17 00:00:00 2001 From: Shawn Patel Date: Tue, 22 Sep 2026 19:09:26 +0000 Subject: [PATCH 36/80] Damiao wrists: KP_APR 400 in config, provisioned at enable like the MyActuator joints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit tune.a4 sweeps on both right wrists (2026-09-22, 12 deg/s triangle, 40 deg/s sine, wrist_2 posed and loaded): stock KP_APR 54 trails the stream by 150 ms (2.0 deg RMS); 400 gives 0.35 deg / 26 ms on both; 800 buzzes wrist_2 (0.12 deg >10 Hz, guard abort). The velocity-loop gains and the profiler ramps changed nothing at 12 deg/s and stay stock. DamiaoMotor.ensure_rom_gains reads each register, writes what differs, stores once with 0xAA and reads back — no reset, the registers apply on write — and apply_firmware_gains provisions Damiao joints through it. The wrists' config block carries position_kp 400 only. tune.a4's held-joint drift is measured against rest_target (the elbow now parks 2 deg inside its stop, and was being reported 2 deg "off" a hold of 0). Co-Authored-By: Claude Fable 5.1 --- almond_axol/cli/tune/a4.py | 14 +++++-- almond_axol/motor/damiao.py | 40 ++++++++++++++++++++ almond_axol/robot/axol.py | 17 +++++++-- almond_axol/robot/config.py | 10 +++++ docs/snippets/config/robot.mdx | 2 +- tests/test_axol_construction.py | 6 +++ tests/test_firmware_gains.py | 65 +++++++++++++++++++++++++++------ 7 files changed, 135 insertions(+), 19 deletions(-) diff --git a/almond_axol/cli/tune/a4.py b/almond_axol/cli/tune/a4.py index 7c4cd07b..c4e10cfa 100644 --- a/almond_axol/cli/tune/a4.py +++ b/almond_axol/cli/tune/a4.py @@ -76,7 +76,7 @@ ) from ...tuning.runner import LiveStream, report_achieved_rate from ..motor import add_side_and_channel_arguments, resolve_channel -from .friction import _home_all, _ramp_verified, _safe_torque_off +from .friction import _home_all, _ramp_verified, _safe_torque_off, rest_target _MA_POS_CONTROL = 0xA4 _MA_MULTI_TURN_ANGLE = 0x92 @@ -987,7 +987,13 @@ async def _run(args: argparse.Namespace) -> None: ) live.flush() held_scores = held_summary( - held_log, {j.value: q for j, q in other_targets.items()} + held_log, + { + j.value: other_targets.get( + j, rest_target(j, getattr(jm, "_is_left", None)) + ) + for j, jm in motors.items() + }, ) if held_scores: print( @@ -1014,7 +1020,9 @@ async def _run(args: argparse.Namespace) -> None: for j, jm in motors.items(): if j == joint: continue - hold = other_targets.get(j, 0.0) + hold = other_targets.get( + j, rest_target(j, getattr(jm, "_is_left", None)) + ) try: pos = await jm.get_position() except MotorError: diff --git a/almond_axol/motor/damiao.py b/almond_axol/motor/damiao.py index 71082367..8bd5a147 100644 --- a/almond_axol/motor/damiao.py +++ b/almond_axol/motor/damiao.py @@ -7,6 +7,7 @@ import asyncio import math import struct +from collections.abc import Mapping from dataclasses import dataclass from enum import Enum from typing import Callable @@ -544,6 +545,45 @@ async def get_gains(self) -> MotorGains: position_ki=float(pos_ki), ) + async def ensure_rom_gains( + self, wanted: Mapping[str, float] + ) -> dict[str, tuple[float, float]]: + """Bring the named loop gains to ``wanted`` in flash. + + ``wanted`` maps ``position_kp`` / ``position_ki`` / ``speed_kp`` / + ``speed_ki`` (KP_APR / KI_APR / KP_ASR / KI_ASR) to values. Each is + read first and written only when it differs beyond float32 rounding; + one 0xAA store follows if anything changed. Writes take effect at + once, so no reset is needed. Returns ``{name: (before, after)}``. + """ + regs = { + "speed_kp": _DM_REG_SPEED_KP, + "speed_ki": _DM_REG_SPEED_KI, + "position_kp": _DM_REG_POS_KP, + "position_ki": _DM_REG_POS_KI, + } + unknown = set(wanted) - set(regs) + if unknown: + raise ValueError(f"Damiao loop has no gain(s) {sorted(unknown)}") + changed: dict[str, tuple[float, float]] = {} + for name, value in wanted.items(): + before = float(await self._read_register(regs[name])) + if abs(before - value) <= 1e-6 * max(1.0, abs(value)): + continue + await self._write_register(regs[name], float(value)) + await asyncio.sleep(0.02) + after = float(await self._read_register(regs[name])) + if abs(after - value) > 1e-6 * max(1.0, abs(value)): + raise MotorError( + f"Damiao motor {self._motor_id:#04x}: wrote {name}={value:g} but " + f"reads back {after:g}" + ) + changed[name] = (before, after) + if changed: + await self._store_parameters() + await asyncio.sleep(0.3) + return changed + async def set_gains(self, gains: MotorGains) -> None: await self._write_register(_DM_REG_SPEED_KP, gains.speed_kp) await self._write_register(_DM_REG_SPEED_KI, gains.speed_ki) diff --git a/almond_axol/robot/axol.py b/almond_axol/robot/axol.py index 98fa5f27..e2e8acb2 100644 --- a/almond_axol/robot/axol.py +++ b/almond_axol/robot/axol.py @@ -34,6 +34,7 @@ MotorGains, MotorStatus, ) +from ..motor.damiao import DamiaoMotor from ..motor.myactuator import MyActuatorMotor from ..settings import SHARED from ..utils.paths import almond_path @@ -195,10 +196,10 @@ async def apply_firmware_gains(arm: "AxolArm", joints: Iterable[Joint]) -> None: if not wanted: continue driver = getattr(arm.motors.get(joint), "_driver", None) - if not isinstance(driver, MyActuatorMotor): + if not isinstance(driver, (MyActuatorMotor, DamiaoMotor)): _logger.warning( - "%s.%s: firmware loop gains configured but the joint is not a " - "MyActuator; ignored", + "%s.%s: firmware loop gains configured but the joint has no " + "firmware position loop; ignored", side, joint.value, ) @@ -215,7 +216,7 @@ async def apply_firmware_gains(arm: "AxolArm", joints: Iterable[Joint]) -> None: exc, ) continue - if changed: + if changed and isinstance(driver, MyActuatorMotor): _logger.info( "%s.%s: firmware loop gains written to ROM: %s — resetting the " "motor so the loop loads them", @@ -224,6 +225,14 @@ async def apply_firmware_gains(arm: "AxolArm", joints: Iterable[Joint]) -> None: ", ".join(f"{n} {b:g} -> {a:g}" for n, (b, a) in changed.items()), ) await driver.reset() + elif changed: + # Damiao registers take effect on write; the store persisted them. + _logger.info( + "%s.%s: firmware loop gains written and stored: %s", + side, + joint.value, + ", ".join(f"{n} {b:g} -> {a:g}" for n, (b, a) in changed.items()), + ) async def _rollback_newly_enabled_motors( diff --git a/almond_axol/robot/config.py b/almond_axol/robot/config.py index 51a9b7a2..76568b5c 100644 --- a/almond_axol/robot/config.py +++ b/almond_axol/robot/config.py @@ -396,6 +396,14 @@ class PositionForceConfig: # is the knee on both at the 400 Hz stream: shoulder_3 0.04° RMS / 9 ms at # 3 deg/s with the tone at 0.06 A (1.4 doubled it); wrist_1 0.12° / 10 ms at # 12 deg/s, tone 0.04 A (0.025 → 0.071 A from 0.7 → 1.4), sine ripple 0.05. +# The Damiao DM-J4310 wrists (wrist_2 / wrist_3): their position-velocity +# loop's KP_APR. Stock 54 trails a 12 deg/s stream by 150 ms (2.0° RMS); +# 400 gives 0.35° / 26 ms on both, 800 buzzes wrist_2 (0.12° >10 Hz, guard +# abort). The velocity-loop gains (KP_ASR 0.0037, KI_ASR 0.002) and the +# profiler ramps changed nothing at 12 deg/s and stay stock; there is no kd. +# Registers take effect on write and are stored — no reset (2026-09-22). +_DM_WRIST_FIRMWARE_GAINS = FirmwareGains(position_kp=400.0) + _X6_ROLL_FIRMWARE_GAINS = FirmwareGains( position_kp=1.0, position_kd=0.5, @@ -526,6 +534,7 @@ class ArmConfig: friction=_ZERO_FRICTION, mass=0.65, com=(0.0, 0.0285, -0.0285), + firmware=_DM_WRIST_FIRMWARE_GAINS, ) ) wrist_3: JointConfig = field( @@ -535,6 +544,7 @@ class ArmConfig: friction=_ZERO_FRICTION, mass=0.75, com=(-0.0285, 0.0, -0.089453), + firmware=_DM_WRIST_FIRMWARE_GAINS, ) ) gripper: PositionForceConfig = field( diff --git a/docs/snippets/config/robot.mdx b/docs/snippets/config/robot.mdx index e6cced8f..d11e6172 100644 --- a/docs/snippets/config/robot.mdx +++ b/docs/snippets/config/robot.mdx @@ -32,7 +32,7 @@ export const F = ({ children }) => {child | --{prefix}left.elbow.friction.fv | per motor | Viscous friction (Nm·s/rad). | | --{prefix}left.elbow.friction.fo | per motor | Constant torque offset (Nm). | | --{prefix}left.elbow.friction.fl | `0.0` | Load-proportional Coulomb friction, Nm per Nm of gravity feed-forward: the effective `fc` is `fc + fl × |gravity|`. Planetary gear friction grows with the torque it carries (right shoulder_1: ~0.6 Nm sliding at rest, ~1.5 Nm under 12 Nm), so a constant `fc` over-compensates at rest and under-compensates at reach. `0` keeps the constant model. | -| --{prefix}left.elbow.firmware.position_kp | `null` (shoulders `1.0`, shoulder_3 and wrist_1 `1.0`, elbow `1.4`) | **MyActuator firmware** position-loop proportional gain, written to the motor's ROM at enable when set (`null` leaves the motor's stored value). These five gains are the motor's own position PI → speed PI controller and only act under `wire_mode` `a4`; identify them with [`tune.a4`](/cli/tune-a4). Stock 0.008 on the X8-P20 shoulders stick-slips at creep speed; the stairs are gone by 0.2 and, with the 400 Hz stream the core runs, tracking keeps improving to 1.0 (0.17° / 12 ms at 12 deg/s) with 1.4 still clean. | +| --{prefix}left.elbow.firmware.position_kp | `null` (shoulders `1.0`, shoulder_3 and wrist_1 `1.0`, elbow `1.4`, Damiao wrists `400`) | Firmware position-loop proportional gain (MyActuator 0x30 index 0x07, Damiao register KP_APR), written to the motor's ROM at enable when set (`null` leaves the motor's stored value). These five gains are the motor's own position PI → speed PI controller and only act under `wire_mode` `a4`; identify them with [`tune.a4`](/cli/tune-a4). Stock 0.008 on the X8-P20 shoulders stick-slips at creep speed; the stairs are gone by 0.2 and, with the 400 Hz stream the core runs, tracking keeps improving to 1.0 (0.17° / 12 ms at 12 deg/s) with 1.4 still clean. | | --{prefix}left.elbow.firmware.position_ki | `null` | Firmware position-loop integral gain. Leave at the stock `0`: on top of the speed integrator it hunts around the target. | | --{prefix}left.elbow.firmware.position_kd | `null` (shoulders and elbow `0.1`, shoulder_3 and wrist_1 `0.5`) | Firmware position-loop derivative gain (protocol V4.2+). The motor stores it, but the 0xA4 loop on the X8-P20 measured inert to it. | | --{prefix}left.elbow.firmware.speed_kp | `null` (shoulders `0.07`, shoulder_3, wrist_1 and elbow `0.05`) | Firmware speed-loop proportional gain. Not a damper in practice — it did nothing for the loop's 5 Hz reversal mode — but the knob on the speed loop's own resonance (~100 Hz on the X8-P20, ~135 Hz on the X6-P20): the current tone grows with it until the loop goes unstable (0.2 on the shoulder, 0.1 at position_kp 1.5 on the elbow). Lower is quieter at no tracking cost. | diff --git a/tests/test_axol_construction.py b/tests/test_axol_construction.py index 861af7e3..2308fe9a 100644 --- a/tests/test_axol_construction.py +++ b/tests/test_axol_construction.py @@ -355,6 +355,12 @@ def setUp(self) -> None: AsyncMock(return_value={}), ) ) + self.enterContext( + patch( + "almond_axol.motor.damiao.DamiaoMotor.ensure_rom_gains", + AsyncMock(return_value={}), + ) + ) def _assert_only_cold_joints_torqued_off(self) -> None: for joint, disable in self.disables.items(): diff --git a/tests/test_firmware_gains.py b/tests/test_firmware_gains.py index 0d8906ea..fbf37e7f 100644 --- a/tests/test_firmware_gains.py +++ b/tests/test_firmware_gains.py @@ -11,6 +11,7 @@ from almond_axol.constants import Joint from almond_axol.motor import MotorError +from almond_axol.motor.damiao import DamiaoMotor from almond_axol.motor.myactuator import _MA_PID_IDX, MyActuatorMotor from almond_axol.robot import FirmwareGains, JointConfig from almond_axol.robot.axol import apply_firmware_gains @@ -54,10 +55,16 @@ def test_x6_roll_joints_share_a_set_on_both_arms(self) -> None: }, ) - def test_damiao_joints_leave_the_motor_alone(self) -> None: - arm = AxolConfig().left - for name in ("wrist_2", "wrist_3"): - self.assertEqual(getattr(arm, name).firmware.as_dict(), {}) + def test_damiao_wrists_carry_the_position_gain_only(self) -> None: + cfg = AxolConfig() + for arm in (cfg.left, cfg.right): + for name in ("wrist_2", "wrist_3"): + self.assertEqual( + getattr(arm, name).firmware.as_dict(), {"position_kp": 400.0} + ) + + def test_the_gripper_has_no_firmware_block(self) -> None: + self.assertFalse(hasattr(AxolConfig().left.gripper, "firmware")) def test_defaults_survive_the_stiffness_blend(self) -> None: cfg = AxolConfig(left_stiffness=0.3).resolved() @@ -103,6 +110,27 @@ async def _request(self, data: bytes, *args, **kwargs) -> bytes: # type: ignore raise AssertionError(f"unexpected frame {data.hex()}") +class _FakeDamiao(DamiaoMotor): + """A Damiao register store: 0x33 reads and 0x55 writes against a dict, with + 0xAA stores counted; no bus.""" + + def __init__(self, store: dict[int, float]) -> None: + super().__init__(MagicMock(), 0x06, 0x16) + self.store = store + self.writes: list[tuple[int, float]] = [] + self.stores = 0 + + async def _read_register(self, rid, timeout=0.2, attempts=5): # type: ignore[override] + return self.store[rid] + + async def _write_register(self, rid, value): # type: ignore[override] + self.writes.append((rid, float(value))) + self.store[rid] = float(value) + + async def _store_parameters(self): # type: ignore[override] + self.stores += 1 + + class _LegacyMotor(_FakeMotor): async def _request(self, data: bytes, *args, **kwargs) -> bytes: # type: ignore[override] # Pre-V4.2 bulk reply: byte 1 is zero, six uint8 gains follow. @@ -239,13 +267,28 @@ async def test_a_refusing_motor_warns_and_does_not_fail_enable(self) -> None: await apply_firmware_gains(arm, [Joint.SHOULDER_1]) self.assertTrue(any("right.shoulder_1" in m for m in logs.output)) - async def test_gripper_and_non_myactuator_joints_are_skipped(self) -> None: - arm = _arm({Joint.GRIPPER: object(), Joint.WRIST_2: object()}) - # Gripper config has no firmware block; wrist_2 has an empty one. - await apply_firmware_gains(arm, [Joint.GRIPPER, Joint.WRIST_2]) + async def test_gripper_is_skipped(self) -> None: + # Gripper config has no firmware block at all. + await apply_firmware_gains(_arm({Joint.GRIPPER: object()}), [Joint.GRIPPER]) - async def test_configured_gains_on_a_damiao_joint_warn(self) -> None: - arm = _arm({Joint.WRIST_2: object()}) + async def test_damiao_wrist_is_provisioned_through_its_registers_without_a_reset( + self, + ) -> None: + w2 = _FakeDamiao({25: 0.0037, 26: 0.002, 27: 54.0, 28: 0.0}) + arm = _arm({Joint.WRIST_2: w2}) + with self.assertLogs("almond_axol.robot.axol", level="INFO") as logs: + await apply_firmware_gains(arm, [Joint.WRIST_2]) + self.assertEqual(w2.store[27], 400.0) + self.assertEqual(w2.stores, 1) + self.assertTrue(any("written and stored" in m for m in logs.output)) + # Second pass: already provisioned — no write, no store. + w2.writes.clear() + w2.stores = 0 + await apply_firmware_gains(arm, [Joint.WRIST_2]) + self.assertEqual((w2.writes, w2.stores), ([], 0)) + + async def test_configured_gains_on_a_joint_without_a_loop_warn(self) -> None: + arm = _arm({Joint.WRIST_2: object()}) # a driver of neither vendor arm._arm_config = replace( arm._arm_config, wrist_2=replace( @@ -254,7 +297,7 @@ async def test_configured_gains_on_a_damiao_joint_warn(self) -> None: ) with self.assertLogs("almond_axol.robot.axol", level="WARNING") as logs: await apply_firmware_gains(arm, [Joint.WRIST_2]) - self.assertTrue(any("not a MyActuator" in m for m in logs.output)) + self.assertTrue(any("no firmware position loop" in m for m in logs.output)) if __name__ == "__main__": From 9e88783ae64231fb46ea6d789d4a1fdbd016b23d Mon Sep 17 00:00:00 2001 From: Shawn Patel Date: Tue, 22 Sep 2026 19:38:09 +0000 Subject: [PATCH 37/80] Controller option: impedance (MIT, 240 Hz) or the firmware position loops (a4/pv, 400 Hz) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `AxolConfig.controller` picks the control law for teleop and recorded motion: `impedance` (default, unchanged) or `position`, which bakes every joint's `wire_mode` to its vendor's firmware position loop — `a4` on the MyActuator joints, the new `pv` (Damiao position-velocity, 0x100+ID) on the wrists — and runs the core at 400 Hz. `tune.motion --controller`, teleop `--axol.controller`, and a select on the dashboard's Recorded-motion tab. Core (proto 10): `WireMode::Pv` with the pos-vel codec; bring-up puts a wrist's control-mode register in the wire's mode instead of refusing, and the bus loop toggles it (pv <-> MIT) when limp / gravity comp wants the compliant frame, since the firmware ignores the other mode's frame. Above 300 Hz the schedule is thinned to a fixed 14-frame tick: every MyActuator command still goes out every tick, the wrists alternate (200 Hz each), and one lane rotates the a4 0x92 reads with the gripper; between reads an a4 joint's position is carried on the speed its echo reports. Off-ticks are not counted as missed feedback. The passive timing observer infers 240 vs 400 Hz from the measured command rate, and the dashboard's timing panel follows it. Co-Authored-By: Claude Fable 5.1 --- almond_axol/cli/tune/motion.py | 29 +- almond_axol/robot/config.py | 130 +++++- almond_axol/rt/link.py | 2 +- almond_axol/rt/robot.py | 64 ++- almond_axol/serve/introspect.py | 1 + docs/cli/teleop.mdx | 2 +- docs/cli/tune-motion.mdx | 2 + docs/guides/diagnostics-dashboard.mdx | 4 +- docs/snippets/config/robot.mdx | 3 +- rust/axol-rt/src/bringup.rs | 64 ++- rust/axol-rt/src/proto.rs | 52 +++ rust/axol-rt/src/serve.rs | 424 +++++++++++++++++- rust/axol-rt/src/timing.rs | 48 +- tests/test_controller_option.py | 172 +++++++ tests/test_rt_link.py | 2 +- .../components/diagnostics/control-health.tsx | 23 +- .../diagnostics/tuning-workbench.tsx | 34 +- 17 files changed, 960 insertions(+), 96 deletions(-) create mode 100644 tests/test_controller_option.py diff --git a/almond_axol/cli/tune/motion.py b/almond_axol/cli/tune/motion.py index cf30ccc9..e3585b9b 100644 --- a/almond_axol/cli/tune/motion.py +++ b/almond_axol/cli/tune/motion.py @@ -37,6 +37,7 @@ axol tune.motion --motion reach-and-place --stiffness 0.8 axol tune.motion --motion reach-and-place --ik # drive through the IK solver axol tune.motion --motion slow_osc --arms right # one arm only + axol tune.motion --motion slow_osc --controller position # firmware loops, 400 Hz """ from __future__ import annotations @@ -51,7 +52,7 @@ from ...constants import ARM_JOINTS from ...robot import Axol -from ...robot.config import AxolConfig +from ...robot.config import CONTROLLERS, AxolConfig from ...robot.control import ContactWatchdog from ...tuning import save_run, tracking_metrics from ...tuning.motion import ReferenceMotion, list_motions, load_motion @@ -256,6 +257,18 @@ def add_parser(subparsers: argparse._SubParsersAction) -> None: # type: ignore[ "host feedforward and NaN torque telemetry (contact watchdog blind on it); " "everything else about the replay is unchanged, so runs compare directly.", ) + p.add_argument( + "--controller", + choices=CONTROLLERS, + default=None, + help="Which control law the core runs the arms on for this run. " + "'impedance' (the config default) is the production MIT frame at 240 Hz " + "with the host feedforward; 'position' puts every joint on its motor's " + "own position loop (MyActuator 0xA4, Damiao position-velocity; the " + "firmware.* gains) streamed at 400 Hz — stiff, no host feedforward, " + "NaN torque on the MyActuator joints. --a4 still adds single joints " + "inside the impedance controller.", + ) p.add_argument( "--arms", choices=("both", "left", "right"), @@ -499,6 +512,17 @@ async def _run(args: argparse.Namespace) -> None: raise SystemExit(f"--a4: unknown joint {joint!r}") getattr(getattr(config, side), joint).wire_mode = "a4" print(f" wire mode: {side}.{joint} = a4 (firmware position loop)") + if args.controller is not None: + config.controller = args.controller + print( + f" controller: {config.controller} " + f"({config.loop_hz:.0f} Hz core loop" + + ( + ", every joint on its firmware position loop)" + if config.controller == "position" + else ")" + ) + ) # The kinematics stack plans the collision-aware approach/return moves. print("Loading kinematics solver (JIT compile may take a few seconds) ...") @@ -760,6 +784,9 @@ async def execute( # Joints driven on the firmware position loop (--a4) for this # run, so the dashboard can re-arm the same controller split. "a4": list(args.a4), + # The control law the whole run ran on (impedance at 240 Hz + # or the firmware position loops at 400 Hz). + "controller": config.controller, **stream_info, }, label=args.label, diff --git a/almond_axol/robot/config.py b/almond_axol/robot/config.py index 76568b5c..98892355 100644 --- a/almond_axol/robot/config.py +++ b/almond_axol/robot/config.py @@ -32,7 +32,8 @@ from dataclasses import dataclass, field, fields, replace from typing import Any -from ..constants import ARM_JOINTS +from ..constants import ARM_JOINTS, Joint +from ..motor.motor import _JOINT_CONFIG from .calibration import ( CALIBRATION_PATH, FACTORY_CALIBRATION_PATH, @@ -264,27 +265,35 @@ class JointConfig: dither_hz: Dither frequency; above the arm's structural modes (~35 Hz), below the core's 120 Hz Nyquist. wire_mode: Which frame the realtime core commands this joint with - (MyActuator joints only; Damiao joints, the gripper, - gravity comp and the limp fallback always use MIT). - ``"mit"`` (default) is the impedance frame and the - production law. ``"a4"`` hands the joint to the firmware's - own position loop (0xA4 absolute position closed-loop, - speed-capped at the tracker's velocity limit) from its - first frame, holds included — the X6-P20's 2025070202 - firmware ignores 0xA4 after an MIT frame until the motor - is reset, so an a4 joint hand-guided in gravity comp needs - a re-enable before it tracks again. Its position/speed PI - on the motor-side encoder is the candidate for creeping - through the X8-P20's stick-slip; its gains are the - ``firmware`` block below and its stored planner - acceleration must be 0 or 60000 (see ``tune.a4``). - Costs: no compliance (the joint holds position with - integral action and pushes back up to motor torque), no - host feedforward (gravity, friction, stiction, dither and - damping are all inert), and no torque telemetry — the - reply carries q-axis current, so measured torque reads - NaN and the contact watchdog is blind on that joint. - Position stays 0.01° via a paired 0x92 read each tick. + (the gripper, gravity comp and the limp fallback always + use MIT). ``"mit"`` (default) is the impedance frame and + the production law. ``"a4"`` hands a **MyActuator** joint + to the firmware's own position loop (0xA4 absolute + position closed-loop, speed-capped at the tracker's + velocity limit) from its first frame, holds included — + the X6-P20's 2025070202 firmware ignores 0xA4 after an + MIT frame until the motor is reset, so an a4 joint + hand-guided in gravity comp needs a re-enable before it + tracks again. Its position/speed PI on the motor-side + encoder is the candidate for creeping through the + X8-P20's stick-slip; its gains are the ``firmware`` block + below and its stored planner acceleration must be 0 or + 60000 (see ``tune.a4``). ``"pv"`` is the same thing for a + **Damiao** wrist: its position-velocity mode (0x100+ID, + control-mode register 2, which the core sets at bring-up + and toggles back to MIT for limp / gravity comp), gains + ``firmware.position_kp`` etc. in the KP_APR/KP_ASR + registers. Costs of either: no compliance (the joint + holds position with integral action and pushes back up to + motor torque), no host feedforward (gravity, friction, + stiction, dither and damping are all inert), and on a4 no + torque telemetry — the reply carries q-axis current, so + measured torque reads NaN and the contact watchdog is + blind on that joint (a pv wrist keeps its torque channel). + Position stays 0.01° on a4 via a paired 0x92 read. + ``AxolConfig.controller`` ``"position"`` sets every + joint's position wire mode at once (and runs the core at + 400 Hz); this field is the per-joint override. stribeck_gain: Friction cancellation on *measured* velocity (see :func:`almond_axol.robot.control.stribeck_excess`), as a fraction of the measured static-minus-sliding excess. @@ -890,6 +899,46 @@ def _apply_stiffness(arm: ArmConfig, s: float | Sequence[float]) -> ArmConfig: ) +#: The two control laws the realtime core can run the arms on +#: (:attr:`AxolConfig.controller`). +#: +#: ``"impedance"`` is the production MIT frame: host gravity / friction / +#: inertia feedforward and host damping around the firmware PD, compliant, +#: at 240 Hz. ``"position"`` hands every joint to its motor's own position +#: loop — 0xA4 on the MyActuator joints, position-velocity on the Damiao +#: wrists, gains from each joint's ``firmware`` block — streamed at 400 Hz, +#: where the loop's target staircase (audible at 200 Hz) is gone. It is +#: stiff: no compliance, no host feedforward, the contact watchdog blind on +#: the MyActuator joints. The bus cannot carry every motor every tick at +#: 400 Hz, so the core thins its schedule (wrists commanded on alternate +#: ticks, one a4 fine-position read per tick round-robin, gripper in that +#: rotation); the MyActuator commands themselves go out every tick. +CONTROLLERS: tuple[str, ...] = ("impedance", "position") + +#: Realtime-core tick rate under each controller (see :data:`CONTROLLERS`). +CONTROLLER_LOOP_HZ: dict[str, float] = {"impedance": 240.0, "position": 400.0} + + +def position_wire_mode(joint: Joint) -> str: + """The firmware-position-loop wire token for an arm joint's vendor. + + ``"a4"`` for the MyActuator joints (ids 1-5), ``"pv"`` for the Damiao + wrists. + """ + return "pv" if _JOINT_CONFIG[joint].motor_id >= 6 else "a4" + + +def _on_position_loops(arm: ArmConfig) -> ArmConfig: + """Every arm joint on its vendor's firmware position loop.""" + return replace( + arm, + **{ + j.value: replace(getattr(arm, j.value), wire_mode=position_wire_mode(j)) + for j in ARM_JOINTS + }, + ) + + @dataclass class AxolConfig: """Top-level configuration for both arms and grippers. @@ -935,6 +984,18 @@ class AxolConfig: round-trips cleanly (loading a dumped config and resolving it again is idempotent). right_stiffness: Same, for the **right** arm. + controller: Which control law the realtime core runs the arms + on — see :data:`CONTROLLERS`. ``"impedance"`` + (default) is the production MIT frame at 240 Hz. + ``"position"`` puts every joint on its firmware + position loop (``wire_mode`` ``a4`` / ``pv``, the + ``firmware`` gains) at 400 Hz. Like stiffness it is + baked into the per-joint ``wire_mode`` fields by + :meth:`resolved`; a per-joint ``wire_mode`` set + explicitly under ``"impedance"`` is kept, so one + joint can still be tried on its firmware loop + inside the impedance controller (``tune.motion + --a4``). """ left: ArmConfig = field( @@ -947,6 +1008,12 @@ class AxolConfig: max_step_rad: float = 0.5 left_stiffness: float | list[float] = 1.0 right_stiffness: float | list[float] = 1.0 + controller: str = "impedance" + + @property + def loop_hz(self) -> float: + """The realtime-core tick rate this controller runs at.""" + return CONTROLLER_LOOP_HZ[self.controller] def resolved(self) -> "AxolConfig": """Return a copy with stiffness baked into the ``left``/``right`` gains. @@ -959,11 +1026,26 @@ def resolved(self) -> "AxolConfig": applied once at the single robot-construction boundary (``Axol.__init__``) so every consumer sees consistent gains while the unresolved config stays safe to serialize and reload. + + The ``controller`` is baked in the same way: ``"position"`` sets + every joint's ``wire_mode`` to its vendor's firmware position loop + (:func:`position_wire_mode`); ``"impedance"`` leaves the per-joint + fields as configured. The field itself is kept (the core reads its + loop rate from it). """ + if self.controller not in CONTROLLERS: + raise ValueError( + f"controller {self.controller!r} is not one of {list(CONTROLLERS)}" + ) + left = _apply_stiffness(self.left, self.left_stiffness) + right = _apply_stiffness(self.right, self.right_stiffness) + if self.controller == "position": + left = _on_position_loops(left) + right = _on_position_loops(right) return replace( self, - left=_apply_stiffness(self.left, self.left_stiffness), - right=_apply_stiffness(self.right, self.right_stiffness), + left=left, + right=right, left_stiffness=1.0, right_stiffness=1.0, ) diff --git a/almond_axol/rt/link.py b/almond_axol/rt/link.py index 852244a9..9937f5e2 100644 --- a/almond_axol/rt/link.py +++ b/almond_axol/rt/link.py @@ -41,7 +41,7 @@ #: both together whenever the config or target layout changes meaning — a #: package and a binary from different checkouts must fail at configure #: time, not arm and then silently reject every target. -CONFIG_PROTO = 9 +CONFIG_PROTO = 10 def config_header() -> list[str]: diff --git a/almond_axol/rt/robot.py b/almond_axol/rt/robot.py index 04e093d0..ae31aa51 100644 --- a/almond_axol/rt/robot.py +++ b/almond_axol/rt/robot.py @@ -101,7 +101,12 @@ _LIMP_KD = 0.25 # Wire-mode tokens the core understands (``bringup::WireMode::parse``). -_WIRE_MODES = frozenset({"mit", "a4"}) +_WIRE_MODES = frozenset({"mit", "a4", "pv"}) +#: The firmware-position-loop tokens and the motor ids they are valid for: +#: ``a4`` is a MyActuator command (ids 1-5), ``pv`` a Damiao one (the +#: wrists, 6-7). The core would refuse neither on the wire — the motor would +#: simply ignore the frame — so the mismatch is caught here. +_WIRE_VENDOR_IDS = {"a4": range(1, 6), "pv": range(6, 8)} class Axol(RobotBase): @@ -140,7 +145,7 @@ def __init__( left_joints: Iterable[Joint] | None = None, right_joints: Iterable[Joint] | None = None, *, - loop_hz: float = 240.0, + loop_hz: float | None = None, watchdog_ms: float = 150.0, max_vel: float = 2.0 * math.pi, max_accel: float = 7.0 * math.pi, @@ -174,7 +179,10 @@ def __init__( changing: Args: - loop_hz: Core tick rate. + loop_hz: Core tick rate. ``None`` (default) follows + ``config.controller``: 240 Hz on the impedance controller, + 400 Hz on the firmware position controller + (:data:`almond_axol.robot.config.CONTROLLER_LOOP_HZ`). watchdog_ms: Core watchdog — how long it holds the last target without a fresh one before treating the host as gone. max_vel: Teleop joint-velocity cap (rad/s) — the core's tracker @@ -206,7 +214,7 @@ def _wrap( cls, hardware: AxolHardware, *, - loop_hz: float = 240.0, + loop_hz: float | None = None, watchdog_ms: float = 150.0, max_vel: float = 2.0 * math.pi, max_accel: float = 7.0 * math.pi, @@ -233,13 +241,15 @@ def _init_core( self, hardware: AxolHardware, *, - loop_hz: float, + loop_hz: float | None, watchdog_ms: float, max_vel: float, max_accel: float, record: str | None, ) -> None: self._robot = hardware + if loop_hz is None: + loop_hz = self._axol_config().loop_hz # ``_core_started``: an ``axol-rt`` process exists for this session # (from ``enable`` until teardown) — teardown must go through the # core. ``_armed``: the core holds the buses (from its ``arm`` ack @@ -306,6 +316,10 @@ def _require_quiet_bus(self, what: str) -> None: "enable(), or after disable()." ) + def _axol_config(self) -> AxolConfig: + """The (resolved) robot config the arms were built from.""" + return self._arms()[0][1]._config + def _arms(self) -> list[tuple[int, AxolArm]]: out = [] if self._robot.left is not None: @@ -315,15 +329,24 @@ def _arms(self) -> list[tuple[int, AxolArm]]: return out def _config_text(self) -> str: - def _wire_token(mode: str) -> str: + def _wire_token(mode: str, joint: Joint, motor_id: int) -> str: token = str(mode).lower() if token not in _WIRE_MODES: raise ValueError( f"wire_mode {mode!r} is not one of {sorted(_WIRE_MODES)}" ) + ids = _WIRE_VENDOR_IDS.get(token) + if ids is not None and motor_id not in ids: + vendor = "MyActuator" if token == "a4" else "Damiao" + raise ValueError( + f"wire_mode {token!r} is the {vendor} position loop; " + f"{joint.value} (motor {motor_id}) is not a {vendor} motor — " + f"use {'pv' if token == 'a4' else 'a4'}, or " + "AxolConfig.controller = 'position' to pick per vendor" + ) return token - max_step = self._arms()[0][1]._config.max_step_rad + max_step = self._axol_config().max_step_rad lines = [ *config_header(), f"loop_hz {self._loop_hz}", @@ -352,7 +375,7 @@ def _wire_token(mode: str) -> str: f"{f.fc} {f.k} {f.fv} {f.fo} " f"{gains.stiction_gain} {math.radians(gains.stiction_err_deg)} " f"{gains.stiction_load_gain} {gains.dither_nm} {gains.dither_hz} " - f"{_wire_token(gains.wire_mode)} " + f"{_wire_token(gains.wire_mode, j, motor_id)} " f"{gains.stribeck_gain} {gains.stribeck_dfs} " f"{gains.stribeck_load_gain} {gains.stribeck_vs} {f.fl} " f"{gains.stribeck_pole}" @@ -364,20 +387,31 @@ def _wire_token(mode: str) -> str: return "\n".join(lines) + "\n" def _warn_wire_modes(self) -> None: - a4 = [ + firmware = [ f"{'left' if side == 0 else 'right'}.{j.value}" for side, arm in self._arms() for j in ARM_JOINTS if j in arm.motors - and str(getattr(arm._arm_config, j.value).wire_mode).lower() == "a4" + and str(getattr(arm._arm_config, j.value).wire_mode).lower() in ("a4", "pv") ] - if a4: + if not firmware: + return + controller = self._axol_config().controller + if controller == "position": _logger.warning( - "rt: %s on the firmware position loop (wire_mode a4): no " - "compliance, no host feedforward, torque telemetry NaN — the " - "contact watchdog cannot see these joints", - ", ".join(a4), + "rt: position controller — every joint on its firmware position " + "loop (a4 / pv) at %.0f Hz: no compliance, no host feedforward, " + "torque telemetry NaN on the MyActuator joints — the contact " + "watchdog cannot see them", + self._loop_hz, ) + return + _logger.warning( + "rt: %s on the firmware position loop (wire_mode a4 / pv): no " + "compliance, no host feedforward, torque telemetry NaN on a4 joints " + "— the contact watchdog cannot see these joints", + ", ".join(firmware), + ) async def enable(self, hold: bool = True) -> None: """Bring every motor up. diff --git a/almond_axol/serve/introspect.py b/almond_axol/serve/introspect.py index 94c953b4..f44413bb 100644 --- a/almond_axol/serve/introspect.py +++ b/almond_axol/serve/introspect.py @@ -34,6 +34,7 @@ "mantis_source": ["quest", "lighthouse", "ultimate"], "dataset_resolution": ["SVGA", "HD1080", "HD1200"], "eyes": ["both", "left", "right"], + "controller": ["impedance", "position"], "policy_type": [ "act", "smolvla", diff --git a/docs/cli/teleop.mdx b/docs/cli/teleop.mdx index a079f31a..b750fd5c 100644 --- a/docs/cli/teleop.mdx +++ b/docs/cli/teleop.mdx @@ -5,7 +5,7 @@ description: "Launch a VR teleoperation session on the robot or simulator." Launches a VR teleoperation session. When started, the hostname (`.local`) and local IP address are printed — enter either of these in the VR app at [axol.almond.bot](https://axol.almond.bot) to connect. -Runs on the **real robot by default**; pass `--sim` for the browser visualizer. Hardware control always uses the required `axol-rt` realtime core, which owns CAN at 240 Hz while Python handles VR, IK, and the slow model. The full robot config is exposed via draccus, so every gain, stiffness, and gripper limit is reachable from the CLI or a config file — see [Command configuration](/cli/configuration) for the syntax. The table below covers the commonly used flags; every field of the nested `axol`, `teleop`, `kinematics`, `vr_server`, and `cart` configs is listed with its default in [Teleoperation Config Parameters](/operations/teleop-config). +Runs on the **real robot by default**; pass `--sim` for the browser visualizer. Hardware control always uses the required `axol-rt` realtime core, which owns CAN — at 240 Hz on the default impedance controller, or at 400 Hz with `--axol.controller position`, which runs every joint on its motor's own firmware position loop (see `controller` in the [robot config](/cli/configuration)) — while Python handles VR, IK, and the slow model. The full robot config is exposed via draccus, so every gain, stiffness, and gripper limit is reachable from the CLI or a config file — see [Command configuration](/cli/configuration) for the syntax. The table below covers the commonly used flags; every field of the nested `axol`, `teleop`, `kinematics`, `vr_server`, and `cart` configs is listed with its default in [Teleoperation Config Parameters](/operations/teleop-config). For a guided, step-by-step walkthrough including the VR controller layout, see the [Teleoperation guide](/operations/teleop). diff --git a/docs/cli/tune-motion.mdx b/docs/cli/tune-motion.mdx index f2deaabd..961460a2 100644 --- a/docs/cli/tune-motion.mdx +++ b/docs/cli/tune-motion.mdx @@ -22,12 +22,14 @@ The arm moves to the motion's start and back to rest on collision-aware planned | `--torque-threshold NM` | Contact watchdog threshold (default: 8.0; 0 disables) | | `--no-save-run` | Don't persist the run artifact (dry run) | | `--a4 SIDE.JOINT` | Drive one MyActuator joint with the firmware position loop (0xA4) instead of the MIT frame for this run, e.g. `right.shoulder_1`; repeatable. No compliance, no host feed-forward, NaN torque on that joint — see `wire_mode` in the [config reference](/cli/configuration) | +| `--controller impedance\|position` | Which control law the core runs the arms on for this run: `impedance` (the config default) is the production MIT frame at 240 Hz with the host feed-forward; `position` puts every joint on its motor's own position loop — MyActuator 0xA4, Damiao position-velocity, the `firmware.*` gains — streamed at 400 Hz. Stiff, no host feed-forward, NaN torque on the MyActuator joints. Same motion and scoring, so the two controllers compare directly; `--a4` still adds single joints inside the impedance controller. See `controller` in the [config reference](/cli/configuration) | | `--arms both\|left\|right` | Which arm(s) to bring up and drive (default: `both`). The other arm's bus is left untouched, so an unpowered arm or a single-arm bench does not block the run | | `--no-gripper` | Run on the gripperless SKU | ```bash axol tune.motion --motion reach-and-place # baseline run axol tune.motion --motion reach-and-place --gain left.elbow.kd=4.5 # A/B a gain +axol tune.motion --motion slow_osc --controller position # firmware position loops, 400 Hz axol tune.motion --motion reach-and-place --gain shoulder_3.kd_host=8 --label "s3 damp" axol tune.motion --motion reach-and-place --noise network # raw noise, no filters axol tune.motion --motion reach-and-place --noise network --filter # same noise, stack on diff --git a/docs/guides/diagnostics-dashboard.mdx b/docs/guides/diagnostics-dashboard.mdx index 9f79abd8..d0cadc3c 100644 --- a/docs/guides/diagnostics-dashboard.mdx +++ b/docs/guides/diagnostics-dashboard.mdx @@ -21,7 +21,7 @@ The selected profile and saved mapping drive the whole dashboard: diagnostics an ## Control loop and CAN timing -The **Control loop & CAN timing** panel measures what actually reached each arm's CAN wire. Keep the Diagnostics page open, then start `axol teleop` from the panel or a terminal. A new graph begins when PyRoKi has produced its first solution and the shared teleop run loop starts; CAN traffic from Rust bring-up and the PyRoKi compile wait is deliberately excluded. Logging stops with that run. Per-arm cards report command and feedback Hz, p95 command-period jitter, worst interval, p95 per-motor command-to-feedback latency, p95 command-send batch, p95 feedback batch, the full first-command-to-last-feedback CAN cycle, bus occupancy and remaining cycle headroom, missed 240 Hz deadlines, and lost feedback frames. Views cover loop rate, jitter, motor latency, full-arm CAN cycle, worst gaps, and misses. The rate and max-gap views include the 240 Hz / 4.17 ms references. +The **Control loop & CAN timing** panel measures what actually reached each arm's CAN wire. Keep the Diagnostics page open, then start `axol teleop` from the panel or a terminal. A new graph begins when PyRoKi has produced its first solution and the shared teleop run loop starts; CAN traffic from Rust bring-up and the PyRoKi compile wait is deliberately excluded. Logging stops with that run. Per-arm cards report command and feedback Hz, p95 command-period jitter, worst interval, p95 per-motor command-to-feedback latency, p95 command-send batch, p95 feedback batch, the full first-command-to-last-feedback CAN cycle, bus occupancy and remaining cycle headroom, missed 240 Hz deadlines, and lost feedback frames. Views cover loop rate, jitter, motor latency, full-arm CAN cycle, worst gaps, and misses. The rate and max-gap views include the loop-rate reference — 240 Hz / 4.17 ms on the impedance controller, 400 Hz / 2.5 ms on the position controller; the observer infers which is running from the measured command rate. These are passive kernel timestamps, not timing printed by the controller being tested. The observer follows the first commanded joint per arm (every teleop tick commands every joint once), pairs each command with its feedback, and adds no CAN frames of its own. **Send batch** is first-to-last arm command; **full cycle** continues through the last arm feedback. **Missed 240 Hz** counts the nominal 4.17 ms cycles represented by longer command gaps: a clean Rust run stays near 240 Hz and zero misses, while a 120 Hz loop visibly records about one missed 240 Hz cycle per command. A card only reads **240 Hz clean** when command and feedback are both within 2% of target, both p95 jitter values are at most 0.5 ms, motor response p95 is at most 3 ms, the CAN cycle retains at least 0.25 ms of p05 headroom, and neither counter reports a miss. @@ -61,7 +61,7 @@ Gain fields (kp, kd, kd_host, kd_host_hz, kd_host_q) show the selected joint's * - **Factory** — [`tune.factory`](https://docs.almond.bot/cli/tune-factory) runs friction + gravity for **all 14 joints** in one go (both arms, distal→proximal), saving every fit to this robot's calibration and uploading the document to the cloud keyed by the hub adapter serial when Supabase credentials are configured. This is the one-button factory calibration; expect a long run. To compare two runs, tick their checkboxes in the run list (same run kind — sine vs sine, motion vs motion). The view switches to an overlay: commanded plus both runs' outputs per joint (A yellow, B blue), both error traces in the lane, a verdict line naming the better run by its headline score, and an A-vs-B score table with the better value of each pair in green. Untick (or Exit compare) to go back to single-run view. -- **Recorded motion** — [`tune.motion`](https://docs.almond.bot/cli/tune-motion) replays a committed reference motion through the production Rust-core control path and scores tracking per joint. The gain table shows every joint's current config value (defaults + this robot's calibration) for each gain field; edit a cell to override it **for this run only, on both arms** — edited cells highlight, everything else runs with config. Clearing a cell drops the override and snaps it back to the config value (**reset overrides** clears them all). The **controller per joint** grid below it picks, per arm and MyActuator joint, whether that joint runs on the production **impedance** frame or on the motor's own **firmware** position loop (`--a4 side.joint`, the gains on the Firmware-loop tab) for this run — e.g. shoulder_1 on the firmware loop with everything else on impedance, scored against the identical motion; a joint the config already pins to `wire_mode a4` shows as firmware and cannot be switched back. Tick **run as IK** to drive the run through the IK solver instead of raw joint replay: the motion's Cartesian end-effector path (FK of the reference, with elbow hints) is re-solved to joints like teleop's pose→joints loop and the arms execute the solver's output — still scored against the clean reference, so IK reconstruction error and tracking show up together in the same charts. The tab also carries the filter-stack test on real hardware: pick a **noise** source (**network** = seeded jitter/outliers/stalls injected before the pose low-pass, **IK** = solver churn/jumps injected after it, or **combined** — each at its real entry point in the pipeline, deterministic per seed) and toggle the **filter stack** (pose low-pass → EMA → trapezoid). The run is scored against the clean reference either way, and the charts overlay **commanded (clean), sent (what was actually streamed), and actual** per joint — run once with the filters off and once on to see, on the physical arm, what the stack removes and what it costs in lag. +- **Recorded motion** — [`tune.motion`](https://docs.almond.bot/cli/tune-motion) replays a committed reference motion through the production Rust-core control path and scores tracking per joint. The gain table shows every joint's current config value (defaults + this robot's calibration) for each gain field; edit a cell to override it **for this run only, on both arms** — edited cells highlight, everything else runs with config. Clearing a cell drops the override and snaps it back to the config value (**reset overrides** clears them all). The **controller** select picks the control law for the whole run: **impedance** (the production MIT frame with the host feed-forward, 240 Hz) or **position** (`--controller position`: every joint on its motor's own firmware position loop — MyActuator 0xA4, Damiao position-velocity, the gains on the Firmware-loop tab — streamed at 400 Hz). The **controller per joint** grid below it picks, inside the impedance controller, per arm and MyActuator joint, whether that joint runs on **impedance** or on its **firmware** loop (`--a4 side.joint`) for this run — e.g. shoulder_1 on the firmware loop with everything else on impedance, scored against the identical motion; a joint the config already pins to `wire_mode a4` shows as firmware and cannot be switched back. Tick **run as IK** to drive the run through the IK solver instead of raw joint replay: the motion's Cartesian end-effector path (FK of the reference, with elbow hints) is re-solved to joints like teleop's pose→joints loop and the arms execute the solver's output — still scored against the clean reference, so IK reconstruction error and tracking show up together in the same charts. The tab also carries the filter-stack test on real hardware: pick a **noise** source (**network** = seeded jitter/outliers/stalls injected before the pose low-pass, **IK** = solver churn/jumps injected after it, or **combined** — each at its real entry point in the pipeline, deterministic per seed) and toggle the **filter stack** (pose low-pass → EMA → trapezoid). The run is scored against the clean reference either way, and the charts overlay **commanded (clean), sent (what was actually streamed), and actual** per joint — run once with the filters off and once on to see, on the physical arm, what the stack removes and what it costs in lag. - **Build motion** — [`motion.build`](https://docs.almond.bot/cli/motion-build) turns a recorded session — teleoperated (`axol teleop --teleop.record`) or hand-guided in gravity comp (`axol gravity-comp --record`, also settable per run on the gravity-comp operation panel) — into a reference motion, which then shows up in the Recorded motion picker. Every build saves a **before/after run**: the raw recorded command stream vs the built motion per joint (with a built−recorded lane), so the smoothing pass and the collision projection are visually checkable — with the same zoom/fullscreen as every other chart — before the motion is replayed. - **IK** — [`diag.offline kinematics`](https://docs.almond.bot/cli/diag-offline) analyzes a teleop recording offline (no hardware, defaults to the newest recording): per-axis charts of the **EE pose set vs actual** (the world target given to the solver vs FK of what it solved) with an error lane in mm, the per-tick **solve time** trace (spikes here surface as teleop lurches), and a per-joint churn/jitter scorecard that catches a restless null space. diff --git a/docs/snippets/config/robot.mdx b/docs/snippets/config/robot.mdx index d11e6172..70a38d32 100644 --- a/docs/snippets/config/robot.mdx +++ b/docs/snippets/config/robot.mdx @@ -6,6 +6,7 @@ export const F = ({ children }) => {child | --{prefix}right_stiffness | `1.0` | Same, for the right arm. | | --{prefix}has_gripper | `true` | Set `false` on the gripperless model. The gripper motors are never enabled and datasets record 7 channels per arm instead of 8. | | --{prefix}max_step_rad | `0.5` | Largest joint move (rad) allowed between two consecutive commands. Bigger moves are dropped with a warning. `inf` disables. | +| --{prefix}controller | `impedance` | Which control law the realtime core runs the arms on. `impedance` is the production MIT frame at **240 Hz**: host gravity / friction / inertia feed-forward and host damping around the firmware PD, compliant. `position` hands every joint to its motor's own position loop at **400 Hz** — 0xA4 on the MyActuator joints, position-velocity on the Damiao wrists, the `firmware.*` gains below (written to ROM at enable) — where the position loop's target staircase (audible at 200 Hz) is gone. It is stiff: no compliance, no host feed-forward, and the contact watchdog is blind on the MyActuator joints (their torque telemetry reads NaN). At 400 Hz the bus cannot carry every motor every tick, so the core thins its schedule: every MyActuator command still goes out every tick, the two wrists are commanded on alternate ticks (200 Hz each), and one 0.01° a4 position read per tick rotates through the a4 joints and the gripper (~67 Hz each; between reads a joint's position is carried on the speed its command echo reports every tick). The choice is baked into each joint's `wire_mode` when the robot is constructed; under `impedance` a per-joint `wire_mode` set explicitly is kept. Recorded-motion replays take `--controller` ([`tune.motion`](/cli/tune-motion)); teleop takes `--axol.controller position`. | | --{prefix}left.gripper.torque_limit | `0.5` | Grip force — peak gripper torque (Nm). | | --{prefix}left.gripper.max_speed | `10.0` | Maximum gripper speed (rad/s). | | --{prefix}left.elbow.kp | per joint | Position stiffness of one joint, in `[0, 500]`. This is the stiffness-`1` value. | @@ -24,7 +25,7 @@ export const F = ({ children }) => {child | --{prefix}left.elbow.stribeck_load_gain | `0.1` | Growth of that excess per Nm of gravity feed-forward (right shoulder_1: ~1.5 Nm under 12 Nm). | | --{prefix}left.elbow.stribeck_vs | `0.1` | Speed (rad/s) at which the excess has fallen to 1/e. | | --{prefix}left.elbow.stribeck_pole | `20.0` | Low-pass pole (rad/s) of the measured velocity the term follows. 20 is smooth but ~40° behind a 2.6 Hz surge; 40–80 follows it at the cost of encoder-step noise in the torque. | -| --{prefix}left.elbow.wire_mode | `mit` | Frame the realtime core commands a **MyActuator** joint with while tracking. `mit` is the impedance frame (production). `a4` hands the joint to the firmware's own position loop (0xA4, speed-capped at the tracker limit) with a paired 0x92 read for 0.01° position — the candidate for creeping through X8-P20 stick-slip. The joint is on 0xA4 from its first frame, holds included: the X6-P20's 2025-07 firmware ignores 0xA4 after an MIT frame until the motor is reset (the 2026 X8-P20 firmware switches freely). Costs: no compliance, no host feed-forward, and NaN torque telemetry (the contact watchdog is blind on that joint). Damiao joints, the gripper, gravity comp and the limp fallback always use MIT — so on the older firmware an a4 joint that has been hand-guided needs a re-enable before it tracks again. The joint's stored planner acceleration must be 0 or 60000 (see [`tune.a4`](/cli/tune-a4)); its loop gains come from `firmware.*` below. | +| --{prefix}left.elbow.wire_mode | `mit` | Frame the realtime core commands the joint with while tracking. `mit` is the impedance frame (production). `a4` hands a **MyActuator** joint to the firmware's own position loop (0xA4, speed-capped at the tracker limit) with a paired 0x92 read for 0.01° position — the candidate for creeping through X8-P20 stick-slip. The joint is on 0xA4 from its first frame, holds included: the X6-P20's 2025-07 firmware ignores 0xA4 after an MIT frame until the motor is reset (the 2026 X8-P20 firmware switches freely). `pv` is the same for a **Damiao** wrist: its position-velocity mode (0x100+ID; the core sets control-mode register 2 at bring-up and switches it back to MIT for limp / gravity comp), gains in the KP_APR/KP_ASR registers, ramps in ACC/DEC. Costs of either: no compliance, no host feed-forward, and on a4 NaN torque telemetry (the contact watchdog is blind on that joint; a pv wrist keeps its torque channel). The gripper, gravity comp and the limp fallback always use MIT — so on the older firmware an a4 joint that has been hand-guided needs a re-enable before it tracks again. An a4 joint's stored planner acceleration must be 0 or 60000 (see [`tune.a4`](/cli/tune-a4)); loop gains come from `firmware.*` below. `controller` `position` (above) sets every joint's position wire mode at once; this field is the per-joint override. | | --{prefix}left.elbow.mass | per joint | Mass (kg) of the link, used for gravity compensation. Tune if an arm sags or pushes back in [gravity comp](/operations/gravity-comp). | | --{prefix}left.elbow.com | per joint | Centre of mass `[x, y, z]` (m) of the link. | | --{prefix}left.elbow.friction.fc | per motor | Coulomb friction (Nm). Measured per motor with [`tune.friction`](/cli/tune-friction). | diff --git a/rust/axol-rt/src/bringup.rs b/rust/axol-rt/src/bringup.rs index a9f632ca..c5d0b21b 100644 --- a/rust/axol-rt/src/bringup.rs +++ b/rust/axol-rt/src/bringup.rs @@ -89,6 +89,13 @@ pub enum WireMode { /// per tick for 0.01° position; the reply's torque channel is iq in /// amps, so measured torque is reported as NaN on these joints. A4, + /// Damiao position-velocity mode (0x100 + id, control-mode register 2): + /// the wrist firmware's own position → speed cascade (KP_APR/KP_ASR + /// registers, ACC/DEC ramps) tracks the streamed target under a speed + /// cap. The feedback frame is the MIT one, so position, velocity and + /// torque all come back with each command. Like `A4`, no host + /// feedforward reaches the motor. + Pv, } impl WireMode { @@ -96,9 +103,19 @@ impl WireMode { match token { "mit" => Some(Self::Mit), "a4" => Some(Self::A4), + "pv" => Some(Self::Pv), _ => None, } } + + /// The Damiao control-mode register value a wrist must be in for this + /// wire's command frame to be acted on. + pub fn dm_mode(self) -> u32 { + match self { + Self::Pv => proto::DM_MODE_POS_VEL, + Self::Mit | Self::A4 => proto::DM_MODE_MIT, + } + } } /// A motor that passed bring-up prep: identified, fault-free, ranges known. @@ -292,14 +309,33 @@ pub fn prepare(sock: &CanSock, iface: &str, specs: &[MotorSpec]) -> io::Result= 6) { let id = spec.motor_id as u16; let mode = read_dm_register(sock, id, proto::DM_REG_CTRL_MODE)?; - // Wrists run MIT (1); the gripper must already be in POSITION_FORCE - // (4), set by the Python side's calibration flow before arming. - let expected = if spec.gripper { 4.0 } else { 1.0 }; - if mode != expected { - return Err(err(format!( - "{} (0x{id:02X}): control mode {mode} (expected {expected}) — not enabling", - spec.joint - ))); + if spec.gripper { + // The gripper must already be in POSITION_FORCE (4), set by the + // Python side's calibration flow before arming. + let expected = proto::DM_MODE_POS_FORCE as f64; + if mode != expected { + return Err(err(format!( + "{} (0x{id:02X}): control mode {mode} (expected {expected}) — not enabling", + spec.joint + ))); + } + } else { + // A wrist runs in the mode its wire wants — MIT (1) or, for + // `wire_mode pv`, position-velocity (2). Put it there (RAM + // write, effective at once) rather than refusing: a wrist left + // in the other mode by the previous session is the normal case + // when the controller choice changes between runs. + let wanted = spec.wire.dm_mode(); + if mode != wanted as f64 { + write_dm_register(sock, id, proto::DM_REG_CTRL_MODE, wanted.to_le_bytes())?; + let now = read_dm_register(sock, id, proto::DM_REG_CTRL_MODE)?; + if now != wanted as f64 { + return Err(err(format!( + "{} (0x{id:02X}): control mode {now} after asking for {wanted} — not enabling", + spec.joint + ))); + } + } } let p_max = read_dm_register(sock, id, proto::DM_REG_PMAX)?; let v_max = read_dm_register(sock, id, proto::DM_REG_VMAX)?; @@ -376,6 +412,18 @@ pub fn read_dm_register(sock: &CanSock, motor_id: u16, rid: u8) -> io::Result io::Result<()> { + sock.send( + proto::DM_REG_ARB, + &proto::dm_write_register(motor_id, rid, value), + )?; + std::thread::sleep(Duration::from_millis(5)); + Ok(()) +} + /// Enable every cold motor. Motors found holding by [`prepare`] are left /// exactly as they are (no brake release / enable frame), and are excluded /// from the rollback: a failed cold bring-up must never drop a pre-existing diff --git a/rust/axol-rt/src/proto.rs b/rust/axol-rt/src/proto.rs index e0613441..3569e08c 100644 --- a/rust/axol-rt/src/proto.rs +++ b/rust/axol-rt/src/proto.rs @@ -207,6 +207,36 @@ pub const DM_DISABLE: [u8; 8] = [0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFD] pub const DM_CLEAR_ERRORS: [u8; 8] = [0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFB]; /// Damiao POSITION_FORCE command arbitration base (`0x300 + motor_id`). +/// Damiao control-mode register values (`DM_REG_CTRL_MODE`). The motor +/// only acts on the command frame of the mode it is in: an MIT frame is +/// ignored in POS_VEL and vice versa, so the core switches the register +/// (RAM write, immediate) whenever the frame it wants changes. +pub const DM_MODE_MIT: u32 = 1; +pub const DM_MODE_POS_VEL: u32 = 2; +pub const DM_MODE_POS_FORCE: u32 = 4; + +/// Register write (`0x55`, RAM only) for `motor_id`, sent to 0x7FF. The +/// motor does not acknowledge a write; read the register back to verify. +pub fn dm_write_register(motor_id: u16, rid: u8, value: [u8; 4]) -> [u8; 8] { + let (lo, hi) = ((motor_id & 0xFF) as u8, (motor_id >> 8) as u8); + [lo, hi, 0x55, rid, value[0], value[1], value[2], value[3]] +} + +/// Position-velocity command arbitration base (0x100 + motor id): the +/// firmware position loop (`wire_mode pv`), gains in the KP_APR/KI_APR and +/// KP_ASR/KI_ASR registers, ramps in ACC/DEC. Answered with the same +/// feedback frame as MIT, so no paired read is needed. +pub const DM_POS_VEL_ARB_BASE: u16 = 0x100; + +/// Encode a position-velocity command: `p_des` (rad) and the speed cap +/// (rad/s) as two little-endian f32. +pub fn dm_pos_vel_encode(position: f64, max_speed: f64) -> [u8; 8] { + let mut out = [0u8; 8]; + out[..4].copy_from_slice(&(position as f32).to_le_bytes()); + out[4..].copy_from_slice(&(max_speed.max(0.0) as f32).to_le_bytes()); + out +} + pub const DM_POS_FORCE_ARB_BASE: u16 = 0x300; /// Encode a Damiao POSITION_FORCE command (``): raw f32 target position, @@ -321,6 +351,28 @@ pub fn mit_encode(p_des: f64, v_des: f64, kp: f64, kd: f64, t_ff: f64, r: &MitRa mod tests { use super::*; + /// Mirrors `tune.a4`'s `dm_frame`: `struct.pack(" bool { vendor == Vendor::MyActuator && wire == WireMode::A4 && (tracked || kp > 0.0) } +/// Whether this tick's frame for a joint is the Damiao position-velocity +/// command (0x100 + id): Damiao wrists on `wire_mode pv`, on every tick that +/// commands a position — the same rule as [`a4_wire`]. Limp and gravity +/// comp (`kp == 0`) fall back to MIT for compliance; the bus loop switches +/// the wrist's control-mode register along with the frame, because the +/// firmware ignores the frame of the mode it is not in. +fn pv_wire(vendor: Vendor, wire: WireMode, tracked: bool, kp: f64) -> bool { + vendor == Vendor::Damiao && wire == WireMode::Pv && (tracked || kp > 0.0) +} + +/// Above this loop rate the bus cannot carry every motor's frames every +/// tick and the schedule is thinned (`Thinning`). A 1 Mbps bus moves a +/// frame in ~0.13 ms with the USB adapters in the loop (measured: three a4 +/// joints at 240 Hz ran the bus 66-73% busy, 22 frames in ~2.9 ms). The +/// full eight-motor tick under the position controller — five 0xA4 +/// commands with echoes, five 0x92 reads, two wrist commands, the gripper — +/// is 26 frames, 3.4 ms: it fits a 240 Hz tick (4.17 ms), not a 400 Hz one +/// (2.5 ms, of which `REPLY_GUARD` is reserved). +const THIN_ABOVE_HZ: f64 = 300.0; + +/// The bus schedule when the loop runs faster than the bus (`THIN_ABOVE_HZ`). +/// +/// Every MyActuator command still goes out every tick — that is the point +/// of the higher rate (the position loop's target staircase is audible at +/// 200 Hz and gone at 400). The rest rides in two round-robin lanes of one +/// request/reply pair per tick each, so a full tick is a fixed 14 frames +/// (~1.8 ms, ~72% of a 2.5 ms tick): +/// +/// - the Damiao wrists take turns being commanded (200 Hz each with two +/// wrists; their own profiler shapes the staircase); +/// - the 0xA4 joints take turns having their 0.01° position read (0x92) +/// and the gripper takes a turn in the same lane (about 67 Hz each on +/// the full arm). Between reads an a4 joint's position is carried +/// forward from the last read on the speed its command echo reports +/// every tick (`a4_extrapolate`). +/// +/// Below the threshold nothing is thinned: every motor is commanded, and +/// every a4 joint read, every tick. +struct Thinning { + enabled: bool, + /// Motor indices of the Damiao wrists, one commanded per tick. + dm_lane: Vec, + /// Motor indices of the a4 joints and the gripper, one served per tick. + read_lane: Vec, + /// The gripper's motor index, when the bus has one. + gripper: Option, +} + +impl Thinning { + fn plan(motors: &[ReadyMotor], loop_hz: f64) -> Self { + let enabled = loop_hz > THIN_ABOVE_HZ; + let dm_lane = motors + .iter() + .enumerate() + .filter(|(_, m)| m.vendor == Vendor::Damiao && !m.gripper) + .map(|(i, _)| i) + .collect(); + let read_lane = motors + .iter() + .enumerate() + .filter(|(_, m)| { + m.gripper || (m.vendor == Vendor::MyActuator && m.wire == WireMode::A4) + }) + .map(|(i, _)| i) + .collect(); + let gripper = motors.iter().position(|m| m.gripper); + Self { + enabled, + dm_lane, + read_lane, + gripper, + } + } + + fn turn(lane: &[usize], tick: u64) -> Option { + if lane.is_empty() { + None + } else { + Some(lane[(tick % lane.len() as u64) as usize]) + } + } + + /// Whether motor `idx` is commanded on `tick`. + fn commanded(&self, idx: usize, tick: u64) -> bool { + if !self.enabled { + return true; + } + if self.dm_lane.contains(&idx) { + return Self::turn(&self.dm_lane, tick) == Some(idx); + } + if self.gripper == Some(idx) { + return Self::turn(&self.read_lane, tick) == Some(idx); + } + true + } + + /// Whether an a4 joint's command on `tick` is followed by its 0x92 read. + fn a4_read(&self, idx: usize, tick: u64) -> bool { + !self.enabled || Self::turn(&self.read_lane, tick) == Some(idx) + } +} + +/// An a4 joint's position between 0x92 reads: the last read (or the +/// previous carry) advanced along the speed its 0xA4 echo reports this +/// tick. The echo's speed is 1 dps resolution, so over a 15 ms read +/// interval the carry is within ~0.01° of the next read — the read's own +/// resolution — and a joint at rest (speed 0) never drifts. +fn a4_extrapolate(anchor: (f64, Instant), speed: f64, now: Instant) -> f64 { + anchor.0 + speed * now.saturating_duration_since(anchor.1).as_secs_f64() +} + #[derive(Clone, Copy, Debug, Default)] pub struct JointCmd { pub p_des: f64, @@ -930,7 +1044,7 @@ fn parse_config(text: &str) -> io::Result { // joint // // - // + // // // // gripper @@ -1290,6 +1404,151 @@ mod tests { assert!(!a4_wire(Vendor::Damiao, WireMode::A4, true, 250.0)); } + #[test] + fn pv_joints_follow_the_same_hold_rule_on_damiao_only() { + use crate::bringup::{Vendor, WireMode}; + assert!(pv_wire(Vendor::Damiao, WireMode::Pv, true, 130.0)); + assert!(pv_wire(Vendor::Damiao, WireMode::Pv, false, 130.0)); + assert!(!pv_wire(Vendor::Damiao, WireMode::Pv, false, 0.0)); + assert!(!pv_wire(Vendor::Damiao, WireMode::Mit, true, 130.0)); + assert!(!pv_wire(Vendor::MyActuator, WireMode::Pv, true, 130.0)); + assert_eq!(WireMode::Pv.dm_mode(), proto::DM_MODE_POS_VEL); + assert_eq!(WireMode::Mit.dm_mode(), proto::DM_MODE_MIT); + } + + fn ready(id: u8, vendor: Vendor, wire: WireMode) -> ReadyMotor { + ReadyMotor { + id, + joint: format!("m{id}"), + vendor, + ranges: proto::MitRanges { + p_max: 12.5, + v_max: 30.0, + kp_max: 500.0, + kd_max: 5.0, + t_max: 10.0, + }, + hold_pos: 0.0, + holding: false, + kp: 100.0, + kd: 1.0, + gripper: id == 8, + slot: id as usize - 1, + max_vel: 9.4, + max_accel: 33.0, + fc: 0.0, + k: 0.0, + fv: 0.0, + fo: 0.0, + stiction_gain: 0.0, + stiction_err: 0.0, + stiction_load_gain: 0.0, + dither_nm: 0.0, + dither_hz: 0.0, + wire, + stribeck_gain: 0.0, + stribeck_dfs: 0.0, + stribeck_load_gain: 0.0, + stribeck_vs: 0.0, + fl: 0.0, + } + } + + fn full_arm_position_controller() -> Vec { + let mut v: Vec = (1..=5) + .map(|id| ready(id, Vendor::MyActuator, WireMode::A4)) + .collect(); + v.push(ready(6, Vendor::Damiao, WireMode::Pv)); + v.push(ready(7, Vendor::Damiao, WireMode::Pv)); + v.push(ready(8, Vendor::Damiao, WireMode::Mit)); + v + } + + #[test] + fn thinning_is_off_at_240_hz() { + let motors = full_arm_position_controller(); + let sched = Thinning::plan(&motors, 240.0); + assert!(!sched.enabled); + for tick in 0..20 { + for idx in 0..motors.len() { + assert!(sched.commanded(idx, tick)); + assert!(sched.a4_read(idx, tick)); + } + } + } + + #[test] + fn thinning_at_400_hz_is_a_fixed_fourteen_frame_tick() { + let motors = full_arm_position_controller(); + let sched = Thinning::plan(&motors, 400.0); + assert!(sched.enabled); + let mut wrist_turns = [0u32; 2]; + let mut gripper_turns = 0u32; + let mut reads = [0u32; 5]; + for tick in 0..60u64 { + // Frames this tick: 2 per MyActuator command (echo), 2 per 0x92 + // read, 2 per Damiao command. + let mut frames = 0; + for idx in 0..motors.len() { + if !sched.commanded(idx, tick) { + continue; + } + frames += 2; + match idx { + 0..=4 => { + if sched.a4_read(idx, tick) { + frames += 2; + reads[idx] += 1; + } + } + 5 | 6 => wrist_turns[idx - 5] += 1, + _ => gripper_turns += 1, + } + } + assert_eq!(frames, 14, "tick {tick}"); + // MyActuator joints are never thinned. + for idx in 0..5 { + assert!(sched.commanded(idx, tick)); + } + } + // Two wrists alternate: 200 Hz each. Five reads and the gripper share + // the other lane: 400/6 Hz each. + assert_eq!(wrist_turns, [30, 30]); + assert_eq!(gripper_turns, 10); + assert_eq!(reads, [10; 5]); + } + + #[test] + fn thinning_only_reads_a4_joints_and_leaves_mit_joints_alone() { + let motors = vec![ + ready(1, Vendor::MyActuator, WireMode::Mit), + ready(2, Vendor::MyActuator, WireMode::A4), + ready(6, Vendor::Damiao, WireMode::Pv), + ]; + let sched = Thinning::plan(&motors, 400.0); + assert_eq!(sched.read_lane, vec![1]); + assert_eq!(sched.dm_lane, vec![2]); + for tick in 0..8 { + assert!(sched.commanded(0, tick)); + assert!(sched.commanded(1, tick)); + // The only wrist is commanded every tick; the only a4 joint is + // read every tick. + assert!(sched.commanded(2, tick)); + assert!(sched.a4_read(1, tick)); + } + } + + #[test] + fn a4_carry_follows_the_echo_speed_and_holds_at_rest() { + let t0 = Instant::now(); + let t1 = t0 + Duration::from_millis(10); + let p = a4_extrapolate((1.0, t0), 0.5, t1); + assert!((p - 1.005).abs() < 1e-9); + assert_eq!(a4_extrapolate((1.0, t0), 0.0, t1), 1.0); + // A clock that has not advanced (or ran backwards) adds nothing. + assert_eq!(a4_extrapolate((1.0, t1), 3.0, t0), 1.0); + } + #[test] fn timing_health_isolated_overrun_degrades_not_limps() { // The field record: one 60 ms stall in an otherwise perfect stream. @@ -1455,7 +1714,7 @@ mod tests { #[test] fn parse_config_assigns_slots() { let cfg = parse_config( - "proto 9\n\ + "proto 10\n\ loop_hz 240\n\ joint 0 canL shoulder_1 1 250 3.5 9.4 33.0 0.6 250 0.15 0.02 0 0 0 0 60 mit 0 0.3 0.1 0.1 0 20\n\ joint 0 canL shoulder_2 2 250 3.5 9.4 33.0 0.5 250 0.10 0.0 0 0 0 0 60 mit 0 0.3 0.1 0.1 0 20\n\ @@ -1507,39 +1766,39 @@ mod tests { ); // An unknown wire token is a bad line, not a silent MIT. assert!(parse_config( - "proto 9\njoint 0 canL shoulder_1 1 250 3.5 9.4 33.0 0.6 250 0.15 0.02 0 0 0 0 60 a9 0 0.3 0.1 0.1 0 20\n" + "proto 10\njoint 0 canL shoulder_1 1 250 3.5 9.4 33.0 0.6 250 0.15 0.02 0 0 0 0 60 a9 0 0.3 0.1 0.1 0 20\n" ) .is_err()); // A joint line missing the tracker/friction params (the previous // 7-field layout) must be rejected, not defaulted. - assert!(parse_config("proto 9\njoint 0 canL shoulder_1 1 250 3.5\n").is_err()); + assert!(parse_config("proto 10\njoint 0 canL shoulder_1 1 250 3.5\n").is_err()); // ... and so must the proto-2 … 8 layouts (13 … 24 fields). assert!(parse_config( - "proto 9\njoint 0 canL shoulder_1 1 250 3.5 9.4 33.0 0.6 250 0.15 0.02\n" + "proto 10\njoint 0 canL shoulder_1 1 250 3.5 9.4 33.0 0.6 250 0.15 0.02\n" ) .is_err()); assert!(parse_config( - "proto 9\njoint 0 canL shoulder_1 1 250 3.5 9.4 33.0 0.6 250 0.15 0.02 0 0\n" + "proto 10\njoint 0 canL shoulder_1 1 250 3.5 9.4 33.0 0.6 250 0.15 0.02 0 0\n" ) .is_err()); assert!(parse_config( - "proto 9\njoint 0 canL shoulder_1 1 250 3.5 9.4 33.0 0.6 250 0.15 0.02 0 0 0\n" + "proto 10\njoint 0 canL shoulder_1 1 250 3.5 9.4 33.0 0.6 250 0.15 0.02 0 0 0\n" ) .is_err()); assert!(parse_config( - "proto 9\njoint 0 canL shoulder_1 1 250 3.5 9.4 33.0 0.6 250 0.15 0.02 0 0 0 0 60\n" + "proto 10\njoint 0 canL shoulder_1 1 250 3.5 9.4 33.0 0.6 250 0.15 0.02 0 0 0 0 60\n" ) .is_err()); assert!(parse_config( - "proto 9\njoint 0 canL shoulder_1 1 250 3.5 9.4 33.0 0.6 250 0.15 0.02 0 0 0 0 60 mit\n" + "proto 10\njoint 0 canL shoulder_1 1 250 3.5 9.4 33.0 0.6 250 0.15 0.02 0 0 0 0 60 mit\n" ) .is_err()); assert!(parse_config( - "proto 9\njoint 0 canL shoulder_1 1 250 3.5 9.4 33.0 0.6 250 0.15 0.02 0 0 0 0 60 mit 0 0.3 0.1 0.1\n" + "proto 10\njoint 0 canL shoulder_1 1 250 3.5 9.4 33.0 0.6 250 0.15 0.02 0 0 0 0 60 mit 0 0.3 0.1 0.1\n" ) .is_err()); assert!(parse_config( - "proto 9\njoint 0 canL shoulder_1 1 250 3.5 9.4 33.0 0.6 250 0.15 0.02 0 0 0 0 60 mit 0 0.3 0.1 0.1 0\n" + "proto 10\njoint 0 canL shoulder_1 1 250 3.5 9.4 33.0 0.6 250 0.15 0.02 0 0 0 0 60 mit 0 0.3 0.1 0.1 0\n" ) .is_err()); } @@ -1550,7 +1809,7 @@ mod tests { #[test] fn parse_config_subset_keeps_joint_slots() { let cfg = parse_config( - "proto 9\n\ + "proto 10\n\ joint 0 can0 wrist_2 6 40 1.0 9.4 33.0 0.0 0.0 0.0 0.0 0 0 0 0 60 mit 0 0.3 0.1 0.1 0 20\n\ joint 0 can0 wrist_3 7 40 1.0 9.4 33.0 0.0 0.0 0.0 0.0 0 0 0 0 60 mit 0 0.3 0.1 0.1 0 20\n\ gripper 0 can0 8\n", @@ -1564,15 +1823,15 @@ mod tests { // Arm joint ids outside 1..=7 have no slot; a repeated id would // double-book one. assert!(parse_config( - "proto 9\njoint 0 can0 wrist_3 8 40 1.0 9.4 33.0 0 0 0 0 0 0 0 0 60 mit 0 0.3 0.1 0.1 0 20\n" + "proto 10\njoint 0 can0 wrist_3 8 40 1.0 9.4 33.0 0 0 0 0 0 0 0 0 60 mit 0 0.3 0.1 0.1 0 20\n" ) .is_err()); assert!(parse_config( - "proto 9\njoint 0 can0 bogus 0 40 1.0 9.4 33.0 0 0 0 0 0 0 0 0 60 mit 0 0.3 0.1 0.1 0 20\n" + "proto 10\njoint 0 can0 bogus 0 40 1.0 9.4 33.0 0 0 0 0 0 0 0 0 60 mit 0 0.3 0.1 0.1 0 20\n" ) .is_err()); assert!(parse_config( - "proto 9\n\ + "proto 10\n\ joint 0 can0 wrist_2 6 40 1.0 9.4 33.0 0 0 0 0 0 0 0 0 60 mit 0 0.3 0.1 0.1 0 20\n\ joint 0 can0 wrist_2 6 40 1.0 9.4 33.0 0 0 0 0 0 0 0 0 60 mit 0 0.3 0.1 0.1 0 20\n" ) @@ -1598,12 +1857,12 @@ mod tests { // A future client generation this core does not understand. let err = error_of(&format!("proto 99\n{joint}")); assert!(err.contains("proto 99"), "{err}"); - assert!(err.contains("proto 9"), "{err}"); + assert!(err.contains("proto 10"), "{err}"); // Malformed declarations are bad lines, not silently accepted. assert!(parse_config(&format!("proto\n{joint}")).is_err()); assert!(parse_config(&format!("proto two\n{joint}")).is_err()); // Order does not matter; the line just has to be there. - assert!(parse_config(&format!("{joint}proto 9\n")).is_ok()); + assert!(parse_config(&format!("{joint}proto 10\n")).is_ok()); } } @@ -2200,6 +2459,45 @@ fn bus_loop( // the sample. let mut a4_stage: [(f64, f64); N_SLOTS] = [(0.0, 0.0); N_SLOTS]; let mut a4_follow = vec![false; motors.len()]; + // Which motors this tick tried to command (a thinned motor's off-tick + // is not a missed reply; a dropped send still is). + let mut attempted = vec![false; motors.len()]; + // 0xA4 joints: the last 0.01° position and when it was taken, carried + // forward on the echo's speed between reads (`a4_extrapolate`). Seeded + // from bring-up's own 0x92 read so the first echo-only tick has a base. + let mut a4_anchor: [Option<(f64, Instant)>; N_SLOTS] = [None; N_SLOTS]; + for m in motors.iter() { + if m.vendor == Vendor::MyActuator && m.wire == WireMode::A4 { + a4_anchor[m.slot] = Some((m.hold_pos, Instant::now())); + } + } + // Damiao wrists: the control-mode register the motor is in (bring-up + // put it in the wire's mode). The frame the core wants can change + // (pv ↔ MIT across limp / gravity comp), and the register must follow + // it or the firmware ignores the frame. + let mut dm_mode: Vec = motors + .iter() + .map(|m| { + if m.vendor == Vendor::Damiao && !m.gripper { + m.wire.dm_mode() + } else { + 0 + } + }) + .collect(); + let sched = Thinning::plan(&motors, cfg.loop_hz); + if sched.enabled { + send_text( + out_tx, + b'L', + &format!( + "{iface}: {:.0} Hz loop — bus schedule thinned: {} wrist(s) take turns, {} read-lane entries (a4 position reads + gripper) take turns", + cfg.loop_hz, + sched.dm_lane.len(), + sched.read_lane.len(), + ), + ); + } // Belt-and-braces: sends on a dead bus normally fail fast with ENOBUFS, // but if the socket sndbuf fills first a blocking write would hang the // loop; the timeout turns that into EAGAIN (treated as TX-full). @@ -2476,6 +2774,7 @@ fn bus_loop( // Send all commands back-to-back and remember exactly which // motors were successfully queued in this tick. expected.fill(0); + attempted.fill(false); let mut trace_pending: [Option; N_SLOTS] = [None; N_SLOTS]; for (motor_index, m) in motors.iter().enumerate() { let c = if is_limp && !m.gripper { @@ -2706,6 +3005,14 @@ fn bus_loop( proto::MA_REQ + m.id as u16, proto::ma_a4_encode(p_cmd, m.max_vel.to_degrees()), ) + } else if pv_wire(m.vendor, m.wire, tracked, c.kp) { + // Damiao firmware position loop: same target, the + // tracker's velocity limit as the speed cap; the + // wrist's profiler (ACC/DEC) shapes it further. + ( + proto::DM_POS_VEL_ARB_BASE + m.id as u16, + proto::dm_pos_vel_encode(p_cmd, m.max_vel), + ) } else { let frame = proto::mit_encode(p_cmd, v_wire, c.kp, c.kd, t_ff, &m.ranges); let arb = match m.vendor { @@ -2715,6 +3022,50 @@ fn bus_loop( (arb, frame) } }; + // The bus cannot carry every motor every tick at the + // higher loop rate: a thinned motor's off-tick still ran + // its tracker (above), it just sends nothing. + if !sched.commanded(motor_index, ticks) { + continue; + } + // A Damiao wrist acts only on the frame of the control mode + // it is in. Switch the register (RAM write, immediate) on + // the tick the wanted frame changes — pv ↔ MIT across limp + // or gravity comp — before that frame goes out. + if dm_mode[motor_index] != 0 { + let wanted = if arb == proto::DM_POS_VEL_ARB_BASE + m.id as u16 { + proto::DM_MODE_POS_VEL + } else { + proto::DM_MODE_MIT + }; + if wanted != dm_mode[motor_index] { + let reg = proto::dm_write_register( + m.id as u16, + proto::DM_REG_CTRL_MODE, + wanted.to_le_bytes(), + ); + if let SendOutcome::Sent = + guarded_send(&sock, proto::DM_REG_ARB, ®, &mut enobufs_since)? + { + dm_mode[motor_index] = wanted; + send_text( + out_tx, + b'L', + &format!( + "{iface}: {} control mode → {} ({})", + m.joint, + wanted, + if wanted == proto::DM_MODE_POS_VEL { + "position-velocity, firmware loop" + } else { + "MIT, compliant" + }, + ), + ); + } + } + } + attempted[motor_index] = true; match guarded_send(&sock, arb, &frame, &mut enobufs_since)? { SendOutcome::Sent => expected[motor_index] = 1, SendOutcome::Dropped => {} @@ -2762,7 +3113,7 @@ fn bus_loop( continue; } a4_follow[motor_index] = false; - if expected[motor_index] == 0 { + if expected[motor_index] == 0 || !sched.a4_read(motor_index, ticks) { continue; } if let SendOutcome::Sent = guarded_send( @@ -2823,11 +3174,24 @@ fn bus_loop( match frame.data[0] { 0xA4 => { let (iq, speed, _) = proto::ma_decode_a4_reply(&frame.data); - if mark_unique_expected_reply(&expected, &mut seen, idx) { - pending -= 1; - a4_stage[slot] = (speed, iq); + if expected[idx] >= 2 { + // A 0x92 read follows: stage, let it + // complete the sample. + if mark_unique_expected_reply(&expected, &mut seen, idx) { + pending -= 1; + a4_stage[slot] = (speed, iq); + } + continue; } - continue; + // No read this tick (thinned schedule): the + // echo completes the sample with the last + // fine position carried on its speed. + let now = Instant::now(); + let Some(anchor) = a4_anchor[slot] else { + continue; + }; + let pos = a4_extrapolate(anchor, speed, now); + (idx, pos, speed, f64::NAN) } proto::MA_MULTI_TURN_ANGLE => { let pos = proto::ma_decode_position(&frame.data); @@ -2859,6 +3223,11 @@ fn bus_loop( pending -= 1; let recv_time = Instant::now(); latest[motors[idx].slot] = Some((pos, vel, tau, recv_time)); + // Every accepted sample re-anchors the a4 carry — the 0x92 + // read, the carried echo itself, and the MIT reply of an a4 + // joint that is limp right now, so the carry resumes from + // where the joint really is when it goes back on 0xA4. + a4_anchor[motors[idx].slot] = Some((pos, recv_time)); // The gripper has no damping chain or trace row: its // POSITION_FORCE reply only feeds the telemetry cache. if motors[idx].gripper { @@ -2920,6 +3289,13 @@ fn bus_loop( if motor.gripper { continue; } + if !attempted[idx] { + // Not this motor's tick on the thinned schedule: no + // reply was owed, so none is missing — but the sample + // the damping chain would act on is a tick old. + feedback_fresh[motor.slot] = false; + continue; + } let complete = reply_complete(&expected, &seen, idx); feedback_fresh[motor.slot] = complete; let health = &mut feedback_health[motor.slot]; diff --git a/rust/axol-rt/src/timing.rs b/rust/axol-rt/src/timing.rs index ccbaa08a..b4fd7e67 100644 --- a/rust/axol-rt/src/timing.rs +++ b/rust/axol-rt/src/timing.rs @@ -9,7 +9,25 @@ use std::time::{Duration, Instant}; const JOINTS: usize = 8; const WINDOW: Duration = Duration::from_secs(2); -const TARGET_HZ: f64 = 240.0; +/// The loop rates the core runs at: 240 Hz on the impedance controller, +/// 400 Hz on the firmware position controller (`AxolConfig.controller`). +/// The passive observer is not told which; it takes the one nearest the +/// measured command rate, so deadline misses are counted against the loop +/// that is actually running. +const LOOP_RATES_HZ: [f64; 2] = [240.0, 400.0]; + +/// The nominal loop rate behind a measured command rate (240 Hz until the +/// measurement exists). +fn target_hz(command_hz: Option) -> f64 { + let Some(hz) = command_hz else { + return LOOP_RATES_HZ[0]; + }; + LOOP_RATES_HZ + .iter() + .copied() + .min_by(|a, b| (a - hz).abs().total_cmp(&(b - hz).abs())) + .unwrap_or(LOOP_RATES_HZ[0]) +} #[derive(Default)] struct JointEvents { @@ -133,7 +151,8 @@ impl TimingAggregator { headroom.push(period - cycle); } } - let nominal = 1.0 / TARGET_HZ; + let target = target_hz(rate(&commands)); + let nominal = 1.0 / target; let deadline_misses: usize = command_dt .iter() .map(|dt| ((*dt / nominal + 0.5) as usize).saturating_sub(1)) @@ -162,8 +181,8 @@ impl TimingAggregator { .map(|t| now.duration_since(*t).as_secs_f64() * 1e3) }; Some(format!( - "{{\"sourceJoint\":\"{}\",\"targetHz\":240.0,\"commandHz\":{},\"feedbackHz\":{},\"commandPeriodMs\":{},\"feedbackPeriodMs\":{},\"commandJitterP95Ms\":{},\"feedbackJitterP95Ms\":{},\"commandGapMaxMs\":{},\"feedbackGapMaxMs\":{},\"commandBatchP50Ms\":{},\"commandBatchP95Ms\":{},\"feedbackBatchP95Ms\":{},\"canCycleP50Ms\":{},\"canCycleP95Ms\":{},\"canUtilizationP95Pct\":{},\"canHeadroomP05Ms\":{},\"roundTripP50Ms\":{},\"roundTripP95Ms\":{},\"deadlineMisses\":{},\"missedFeedback\":{},\"commandAgeMs\":{},\"feedbackAgeMs\":{}}}", - names[slot], js(rate(&commands)), js(rate(&feedback)), js(median(&command_dt).map(ms)), + "{{\"sourceJoint\":\"{}\",\"targetHz\":{},\"commandHz\":{},\"feedbackHz\":{},\"commandPeriodMs\":{},\"feedbackPeriodMs\":{},\"commandJitterP95Ms\":{},\"feedbackJitterP95Ms\":{},\"commandGapMaxMs\":{},\"feedbackGapMaxMs\":{},\"commandBatchP50Ms\":{},\"commandBatchP95Ms\":{},\"feedbackBatchP95Ms\":{},\"canCycleP50Ms\":{},\"canCycleP95Ms\":{},\"canUtilizationP95Pct\":{},\"canHeadroomP05Ms\":{},\"roundTripP50Ms\":{},\"roundTripP95Ms\":{},\"deadlineMisses\":{},\"missedFeedback\":{},\"commandAgeMs\":{},\"feedbackAgeMs\":{}}}", + names[slot], js(Some(target)), js(rate(&commands)), js(rate(&feedback)), js(median(&command_dt).map(ms)), js(median(&feedback_dt).map(ms)), js(jitter(&command_dt)), js(jitter(&feedback_dt)), js(command_dt.iter().copied().reduce(f64::max).map(ms)), js(feedback_dt.iter().copied().reduce(f64::max).map(ms)), @@ -277,7 +296,28 @@ mod tests { .snapshot_json(start + Duration::from_millis(14)) .unwrap(); assert!(json.contains("\"sourceJoint\":\"SHOULDER_1\"")); + assert!(json.contains("\"targetHz\":240.000000000")); assert!(json.contains("\"commandHz\":240.000000000")); assert!(json.contains("\"roundTripP95Ms\":1.000000000")); } + + #[test] + fn a_400_hz_stream_is_judged_against_400_hz() { + assert_eq!(target_hz(None), 240.0); + assert_eq!(target_hz(Some(238.0)), 240.0); + assert_eq!(target_hz(Some(396.0)), 400.0); + let mut timing = TimingAggregator::new(); + let start = Instant::now(); + for tick in 0..8 { + let at = start + Duration::from_secs_f64(tick as f64 / 400.0); + timing.observe(0x141, &[0xA4, 0, 0, 0, 0, 0, 0, 0], at); + timing.observe(0x501, &[0; 8], at + Duration::from_micros(500)); + } + let json = timing + .snapshot_json(start + Duration::from_millis(19)) + .unwrap(); + assert!(json.contains("\"targetHz\":400.000000000"), "{json}"); + // 400 Hz ticks are not 240 Hz deadline misses. + assert!(json.contains("\"deadlineMisses\":0"), "{json}"); + } } diff --git a/tests/test_controller_option.py b/tests/test_controller_option.py new file mode 100644 index 00000000..38702819 --- /dev/null +++ b/tests/test_controller_option.py @@ -0,0 +1,172 @@ +"""``AxolConfig.controller``: impedance (MIT, 240 Hz) or the firmware position +loops (a4 / pv, 400 Hz) — baked into the per-joint wire modes at +construction, the core's loop rate and wire tokens derived from it.""" + +from __future__ import annotations + +import argparse +import unittest +from typing import Any +from unittest.mock import patch + +from almond_axol.cli.tune import motion as tune_motion +from almond_axol.constants import ARM_JOINTS, Joint +from almond_axol.robot.axol import AxolHardware +from almond_axol.robot.config import ( + CONTROLLER_LOOP_HZ, + CONTROLLERS, + AxolConfig, + position_wire_mode, +) +from almond_axol.rt import Axol +from almond_axol.serve.introspect import _KNOWN_OPTIONS + +_MYACTUATOR = ( + Joint.SHOULDER_1, + Joint.SHOULDER_2, + Joint.SHOULDER_3, + Joint.ELBOW, + Joint.WRIST_1, +) +_DAMIAO = (Joint.WRIST_2, Joint.WRIST_3) + + +class ConfigTest(unittest.TestCase): + def test_impedance_is_the_default_and_leaves_wire_modes_alone(self) -> None: + cfg = AxolConfig() + self.assertEqual(cfg.controller, "impedance") + self.assertEqual(cfg.loop_hz, 240.0) + resolved = cfg.resolved() + for arm in (resolved.left, resolved.right): + for j in ARM_JOINTS: + self.assertEqual(getattr(arm, j.value).wire_mode, "mit") + + def test_position_puts_every_joint_on_its_vendors_loop_at_400_hz(self) -> None: + cfg = AxolConfig(controller="position") + self.assertEqual(cfg.loop_hz, 400.0) + resolved = cfg.resolved() + self.assertEqual(resolved.controller, "position") + for arm in (resolved.left, resolved.right): + for j in _MYACTUATOR: + self.assertEqual(getattr(arm, j.value).wire_mode, "a4", j) + for j in _DAMIAO: + self.assertEqual(getattr(arm, j.value).wire_mode, "pv", j) + # Idempotent, like the stiffness blend. + self.assertEqual(resolved.resolved(), resolved) + + def test_position_wire_mode_follows_the_motor_vendor(self) -> None: + for j in _MYACTUATOR: + self.assertEqual(position_wire_mode(j), "a4") + for j in _DAMIAO: + self.assertEqual(position_wire_mode(j), "pv") + + def test_an_explicit_a4_joint_survives_under_impedance(self) -> None: + cfg = AxolConfig() + cfg.right.shoulder_1.wire_mode = "a4" + resolved = cfg.resolved() + self.assertEqual(resolved.right.shoulder_1.wire_mode, "a4") + self.assertEqual(resolved.right.shoulder_2.wire_mode, "mit") + self.assertEqual(resolved.loop_hz, 240.0) + + def test_unknown_controller_is_refused(self) -> None: + with self.assertRaises(ValueError): + AxolConfig(controller="velocity").resolved() + + def test_rates_are_pinned(self) -> None: + self.assertEqual(CONTROLLERS, ("impedance", "position")) + self.assertEqual(CONTROLLER_LOOP_HZ, {"impedance": 240.0, "position": 400.0}) + # The dashboard's draccus forms render the field as a select. + self.assertEqual(_KNOWN_OPTIONS["controller"], list(CONTROLLERS)) + + +class _FakeBus: + def __init__(self, channel: str) -> None: + self._channel = channel + + async def close(self) -> None: + return None + + +class _FakeDriver: + kp_max = 500.0 + kd_max = 5.0 + + def __init__(self, *_a: Any, **_k: Any) -> None: + pass + + def set_feedback_callback(self, _cb: Any) -> None: + pass + + +def _hardware(config: AxolConfig) -> AxolHardware: + with ( + patch("almond_axol.robot.axol.CanBus", _FakeBus), + patch( + "almond_axol.motor.motor.make_driver", + side_effect=lambda *_a, **_k: _FakeDriver(), + ), + ): + return AxolHardware(config=config, left_channel="can0", right_channel=None) + + +class RealtimeConfigTest(unittest.TestCase): + def _joint_tokens(self, rt: Axol) -> dict[str, str]: + out = {} + for line in rt._config_text().splitlines(): + f = line.split() + if f and f[0] == "joint": + out[f[3]] = f[18] + return out + + def test_impedance_core_runs_at_240_on_mit(self) -> None: + rt = Axol._wrap(_hardware(AxolConfig())) + lines = rt._config_text().splitlines() + self.assertIn("loop_hz 240.0", lines) + self.assertEqual(set(self._joint_tokens(rt).values()), {"mit"}) + + def test_position_core_runs_at_400_with_a4_and_pv_tokens(self) -> None: + rt = Axol._wrap(_hardware(AxolConfig(controller="position"))) + lines = rt._config_text().splitlines() + self.assertIn("loop_hz 400.0", lines) + tokens = self._joint_tokens(rt) + for j in _MYACTUATOR: + self.assertEqual(tokens[j.value], "a4") + for j in _DAMIAO: + self.assertEqual(tokens[j.value], "pv") + + def test_an_explicit_loop_rate_still_wins(self) -> None: + rt = Axol._wrap(_hardware(AxolConfig(controller="position")), loop_hz=240.0) + self.assertIn("loop_hz 240.0", rt._config_text().splitlines()) + + def test_a_vendor_mismatched_wire_mode_is_refused(self) -> None: + cfg = AxolConfig() + cfg.left.wrist_2.wire_mode = "a4" # a Damiao wrist has no 0xA4 + rt = Axol._wrap(_hardware(cfg)) + with self.assertRaisesRegex(ValueError, "wrist_2.*Damiao|not a MyActuator"): + rt._config_text() + cfg = AxolConfig() + cfg.left.elbow.wire_mode = "pv" # a MyActuator joint has no pos-vel frame + rt = Axol._wrap(_hardware(cfg)) + with self.assertRaisesRegex(ValueError, "elbow"): + rt._config_text() + + +class TuneMotionFlagTest(unittest.TestCase): + def _parse(self, *argv: str) -> argparse.Namespace: + parser = argparse.ArgumentParser() + sub = parser.add_subparsers() + tune_motion.add_parser(sub) + return parser.parse_args(["tune.motion", "--motion", "slow_osc", *argv]) + + def test_controller_flag_takes_the_two_laws_and_defaults_to_config(self) -> None: + self.assertIsNone(self._parse().controller) + self.assertEqual(self._parse("--controller", "position").controller, "position") + self.assertEqual( + self._parse("--controller", "impedance").controller, "impedance" + ) + with self.assertRaises(SystemExit): + self._parse("--controller", "velocity") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_rt_link.py b/tests/test_rt_link.py index 106b6005..255cd29e 100644 --- a/tests/test_rt_link.py +++ b/tests/test_rt_link.py @@ -103,7 +103,7 @@ def test_config_header_declares_the_protocol(self) -> None: # mode token; 7: Stribeck fields; 8: load-proportional friction fl; # 9: the Stribeck velocity pole on every joint line. Bump both sides # together (rust/axol-rt/src/serve.rs CONFIG_PROTO). - self.assertEqual(link.CONFIG_PROTO, 9) + self.assertEqual(link.CONFIG_PROTO, 10) async def test_configure_names_a_stale_binary_when_the_core_exits(self) -> None: rt = self._link(_ExitedProc()) diff --git a/web/app/src/components/diagnostics/control-health.tsx b/web/app/src/components/diagnostics/control-health.tsx index 538d3ae4..fb57a8e9 100644 --- a/web/app/src/components/diagnostics/control-health.tsx +++ b/web/app/src/components/diagnostics/control-health.tsx @@ -86,8 +86,11 @@ function sample(timing: ControlTiming, kind: TimingSeries): (number | null)[] { /** Adapt timing messages to the generic chart's keyed numeric-series shape. */ function chartFrames(frames: TimingFrame[]): TelemetryFrame[] { return frames.map((frame) => { + // The loop rate the core is running (240 Hz impedance, 400 Hz position + // controller) comes from the observer, per frame. + const hz = frame.arms.left?.targetHz ?? frame.arms.right?.targetHz ?? 240 const m: TelemetryFrame["m"] = { - target: [240, null, null, null, 1000 / 240, null], + target: [hz, null, null, null, 1000 / hz, null], } for (const side of SIDES) { const timing = frame.arms[side] @@ -189,7 +192,7 @@ export function ControlHealth({ frames, version, nowT, view, onViewChange }: Con ? [ ...commandSeries, ...feedbackSeries, - { key: "target", label: "4.17 ms target", color: COLORS.target }, + { key: "target", label: "RT period target", color: COLORS.target }, ] : [...commandSeries, ...feedbackSeries] @@ -258,7 +261,11 @@ export function ControlHealth({ frames, version, nowT, view, onViewChange }: Con

{side} arm

- {clean ? "240 Hz clean" : isFresh ? "timing issue" : "idle"} + {clean && timing + ? `${Math.round(timing.targetHz)} Hz clean` + : isFresh + ? "timing issue" + : "idle"} {timing && ( @@ -325,7 +332,9 @@ export function ControlHealth({ frames, version, nowT, view, onViewChange }: Con

-

missed 240 Hz

+

+ missed {timing ? Math.round(timing.targetHz) : 240} Hz +

{timing?.deadlineMisses ?? "–"}/s

@@ -362,8 +371,10 @@ export function ControlHealth({ frames, version, nowT, view, onViewChange }: Con />

Send batch is first-to-last arm command; full cycle continues through the final feedback. - “Missed 240 Hz” counts command gaps that lost one or more 4.17 ms deadlines. All values come - from passive kernel-timestamped evidence on the Rust-owned CAN wire. + “Missed N Hz” counts command gaps that lost one or more loop periods — 4.17 ms on the 240 Hz + impedance controller, 2.5 ms on the 400 Hz position controller; the observer infers which is + running. All values come from passive kernel-timestamped evidence on the Rust-owned CAN + wire.

) diff --git a/web/app/src/components/diagnostics/tuning-workbench.tsx b/web/app/src/components/diagnostics/tuning-workbench.tsx index 982ec00f..338744f1 100644 --- a/web/app/src/components/diagnostics/tuning-workbench.tsx +++ b/web/app/src/components/diagnostics/tuning-workbench.tsx @@ -453,6 +453,23 @@ const TABS: WbTab[] = [ presets: {}, fields: [ { key: "motion", label: "motion", type: "select", options: [] }, + { + key: "controller", + label: "controller", + type: "select", + options: ["impedance", "position"], + placeholder: "impedance", + width: "w-40", + hint: + "impedance (240 Hz) is the production MIT frame: host gravity, " + + "friction, inertia and damping feed-forward around the firmware PD, " + + "compliant. position (400 Hz) hands every joint to its motor's own " + + "position loop — MyActuator 0xA4, Damiao position-velocity, the gains " + + "on the Firmware-loop tab — streamed at 400 Hz, where the loop's " + + "target staircase is gone: stiff, no host feed-forward, NaN torque " + + "on the MyActuator joints (contact watchdog blind there). Same " + + "motion, same scoring, so the two controllers compare directly.", + }, { key: "stiffness", label: "stiffness s", type: "number", placeholder: "1" }, { key: "gain", label: "gains — edit a cell to override it for this run", type: "overrides" }, { @@ -460,14 +477,14 @@ const TABS: WbTab[] = [ label: "controller per joint — click a cell to put that joint on the firmware loop", type: "wire", hint: - "impedance is the production MIT frame with the host's gravity, " + - "friction and damping feed-forward; firmware hands the joint to the " + - "motor's own 0xA4 position loop (the gains on the Firmware-loop tab, " + - "written to ROM at enable) for this run only — no compliance, no host " + - "feed-forward and NaN torque telemetry on that joint. Everything else " + - "about the replay is unchanged, so runs compare directly. Only " + - "MyActuator joints have a firmware loop; the Damiao wrists stay on " + - "impedance. A joint already configured wire_mode a4 is pinned.", + "inside the impedance controller, single MyActuator joints can go on " + + "their 0xA4 firmware loop for this run only (--a4 side.joint), the rest " + + "staying on impedance — no compliance, no host feed-forward and NaN " + + "torque telemetry on that joint. Everything else about the replay is " + + "unchanged, so runs compare directly. The Damiao wrists' firmware loop " + + "(position-velocity) comes with the position controller above, which " + + "puts every joint on its firmware loop at 400 Hz. A joint already " + + "configured wire_mode a4 is pinned.", }, { key: "ik", @@ -1207,6 +1224,7 @@ function runFormValues(meta: TuningRunMeta): Record | null { break case "motion": { put("motion", p.motion) + put("controller", p.controller) put("stiffness", p.stiffness) put("noise", p.noise) if (p.ik === true) out["ik"] = "true" From 4ab216798fc0d24bf878b8d50d4fd7539cc8bc5e Mon Sep 17 00:00:00 2001 From: Shawn Patel Date: Tue, 22 Sep 2026 19:40:06 +0000 Subject: [PATCH 38/80] rt_proto_check: speak the current config protocol (was pinned at proto 2), cover the a4 and pv wire tokens Co-Authored-By: Claude Fable 5.1 --- rust/axol-rt/tools/rt_proto_check.py | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/rust/axol-rt/tools/rt_proto_check.py b/rust/axol-rt/tools/rt_proto_check.py index 8bd1ff03..fc74375b 100644 --- a/rust/axol-rt/tools/rt_proto_check.py +++ b/rust/axol-rt/tools/rt_proto_check.py @@ -12,6 +12,10 @@ import os import struct import subprocess +import sys + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "..")) +from almond_axol.rt.link import CONFIG_PROTO # noqa: E402 BIN = os.environ.get( "AXOL_RT_BIN", @@ -57,8 +61,18 @@ async def recv(): return proc.returncode, out.decode(), result -joint_line = b"joint 0 can_alm_axol_l shoulder_1 1 250 3.5 9.4 33.0 0.6 250 0.15 0.02\n" -cfg = b"C" + b"proto 2\n" + b"loop_hz 240\n" + joint_line +# One joint per wire token, in the current `joint` line layout (see the +# `parse_config` tests in serve.rs): the package's CONFIG_PROTO must be the +# binary's, or the clean check fails with the core's version-skew message. +joint_line = ( + b"joint 0 can_alm_axol_l shoulder_1 1 250 3.5 9.4 33.0 0.6 250 0.15 0.02" + b" 0 0 0 0 60 a4 0 0.3 0.1 0.1 0 20\n" + b"joint 0 can_alm_axol_l elbow 4 130 5.0 9.4 33.0 0.6 250 0.15 0.02" + b" 0 0 0 0 60 mit 0 0.3 0.1 0.1 0 20\n" + b"joint 0 can_alm_axol_l wrist_2 6 130 3.5 9.4 33.0 0 0 0 0" + b" 0 0 0 0 60 pv 0 0.3 0.1 0.1 0 20\n" +) +cfg = b"C" + f"proto {CONFIG_PROTO}\n".encode() + b"loop_hz 400\n" + joint_line async def clean(send, recv, w): @@ -93,10 +107,8 @@ def check_feedback_parse(): its `feedback_packet_layout` unit test) and asserts `RtLink._parse_feedback` recovers the values, including the age -> timestamp reconstruction. """ - import sys import time - sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "..")) from almond_axol.rt.link import RtLink slots_in = {0: (1.5, -0.25, 3.0, 1200), 7: (0.5, 0.0, 0.1, 0)} From 8431942b67ad87a4c517f9563b4f28697016bbdf Mon Sep 17 00:00:00 2001 From: Shawn Patel Date: Tue, 22 Sep 2026 19:57:04 +0000 Subject: [PATCH 39/80] tune.motion --record: the teleop flight recorder on a replay (PREFIX_meas.npz + the core's PREFIX_rt.npz trace) Co-Authored-By: Claude Fable 5.1 --- almond_axol/cli/tune/motion.py | 31 ++++++++++++++++++++++++------- 1 file changed, 24 insertions(+), 7 deletions(-) diff --git a/almond_axol/cli/tune/motion.py b/almond_axol/cli/tune/motion.py index e3585b9b..6152a7fb 100644 --- a/almond_axol/cli/tune/motion.py +++ b/almond_axol/cli/tune/motion.py @@ -257,6 +257,16 @@ def add_parser(subparsers: argparse._SubParsersAction) -> None: # type: ignore[ "host feedforward and NaN torque telemetry (contact watchdog blind on it); " "everything else about the replay is unchanged, so runs compare directly.", ) + p.add_argument( + "--record", + metavar="PREFIX", + default=None, + help="Flight-recorder prefix, as teleop's --teleop.record: the replay's " + "measured joints go to PREFIX_meas.npz and the realtime core's per-tick " + "trace (target, command, measured position, motor speed, feed-forward " + "terms) to PREFIX_rt.npz for `axol diag.teleop-jitter` or offline " + "analysis. A bare name lands in the recordings directory.", + ) p.add_argument( "--controller", choices=CONTROLLERS, @@ -642,7 +652,7 @@ async def execute( arm_channels["left_channel"] = None elif args.arms == "left": arm_channels["right_channel"] = None - robot = Axol(config=config, **arm_channels) + robot = Axol(config=config, record=args.record, **arm_channels) async with robot as axol: contact: tuple[str, float] | None = None @@ -667,12 +677,18 @@ async def execute( raise _NotAtStart(stragglers) print(f"Replaying {motion.duration:.1f} s of motion ...") - contact = await execute( - axol, - traj_playback, - record=True, - refs=ref if stream_differs else None, - ) + # The flight recorder captures the replay segment only, like + # teleop's engage→disengage. + axol.set_recording_engaged(True) + try: + contact = await execute( + axol, + traj_playback, + record=True, + refs=ref if stream_differs else None, + ) + finally: + axol.set_recording_engaged(False) if contact is not None: raise _Contact(contact) except _NotAtStart as exc: @@ -787,6 +803,7 @@ async def execute( # The control law the whole run ran on (impedance at 240 Hz # or the firmware position loops at 400 Hz). "controller": config.controller, + "record": args.record, **stream_info, }, label=args.label, From eb12ea0fed2ccfb62c3208e97ee0de0f1d75c310 Mon Sep 17 00:00:00 2001 From: Shawn Patel Date: Tue, 22 Sep 2026 20:06:42 +0000 Subject: [PATCH 40/80] =?UTF-8?q?Position=20controller=20diagnostics:=20wr?= =?UTF-8?q?ist=20profiler=20ramp=20in=20config=20(ACC/DEC=2050=20rad/s?= =?UTF-8?q?=C2=B2=20at=20enable),=20tune.motion=20--loop-hz=20and=20firmwa?= =?UTF-8?q?re.*=20gain=20overrides?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The wrists shipped with a 2 rad/s² position-velocity ramp, which the 200 Hz pv stream cannot live with (5 Hz hunting, 3x the impedance frame's 3-15 Hz error). FirmwareGains.profile_acc provisions ACC/-DEC like the loop gains. --loop-hz separates the tick rate from the controller for A/B runs, and --gain joint.firmware.speed_kp=... A/Bs the firmware loops on a replay. Co-Authored-By: Claude Fable 5.1 --- almond_axol/cli/tune/motion.py | 36 ++++++++++++++++++++++---- almond_axol/motor/damiao.py | 45 ++++++++++++++++++++++----------- almond_axol/robot/config.py | 15 ++++++++++- docs/cli/tune-motion.mdx | 4 ++- docs/snippets/config/robot.mdx | 1 + tests/test_controller_option.py | 18 +++++++++++++ tests/test_firmware_gains.py | 31 +++++++++++++++++++++-- 7 files changed, 126 insertions(+), 24 deletions(-) diff --git a/almond_axol/cli/tune/motion.py b/almond_axol/cli/tune/motion.py index 6152a7fb..e7e0d618 100644 --- a/almond_axol/cli/tune/motion.py +++ b/almond_axol/cli/tune/motion.py @@ -85,6 +85,15 @@ "friction.fv", "friction.fo", "friction.fl", + # Firmware position-loop gains (``firmware.*`` on JointConfig), for A/B + # runs of the position controller: written to the motors' ROM at enable + # like the config values they replace (so a run leaves them there). + "firmware.position_kp", + "firmware.position_ki", + "firmware.position_kd", + "firmware.speed_kp", + "firmware.speed_ki", + "firmware.profile_acc", ) # Column names of a 14-wide motion row: left arm then right arm. @@ -138,9 +147,10 @@ def _parse_gain_overrides(specs: list[str]) -> dict[tuple[str, str, str], float] value = float(raw) except ValueError: raise SystemExit(f"--gain: bad value in {spec!r} (want PATH=NUMBER)") - # ``[side.]joint.friction.fc``: fold the sub-field back into one token. - if len(parts) >= 2 and parts[-2] == "friction": - parts = parts[:-2] + [f"friction.{parts[-1]}"] + # ``[side.]joint.friction.fc`` / ``joint.firmware.speed_kp``: fold the + # sub-field back into one token. + if len(parts) >= 2 and parts[-2] in ("friction", "firmware"): + parts = parts[:-2] + [f"{parts[-2]}.{parts[-1]}"] if len(parts) == 3: sides, joint, fld = [parts[0]], parts[1], parts[2] if sides[0] not in ("left", "right"): @@ -257,6 +267,17 @@ def add_parser(subparsers: argparse._SubParsersAction) -> None: # type: ignore[ "host feedforward and NaN torque telemetry (contact watchdog blind on it); " "everything else about the replay is unchanged, so runs compare directly.", ) + p.add_argument( + "--loop-hz", + type=float, + default=None, + help="Realtime-core tick rate override. Default follows --controller " + "(240 Hz impedance, 400 Hz position). For A/B runs only: e.g. " + "--controller position --loop-hz 240 to separate the rate from the " + "controller, or --a4 right.elbow --loop-hz 400 for one joint on its " + "firmware loop at the position controller's rate. Above 300 Hz the " + "core thins the bus schedule (wrists on alternate ticks).", + ) p.add_argument( "--record", metavar="PREFIX", @@ -510,6 +531,8 @@ async def _run(args: argparse.Namespace) -> None: target = getattr(getattr(config, side), joint) if fld.startswith("friction."): setattr(target.friction, fld.split(".", 1)[1], value) + elif fld.startswith("firmware."): + setattr(target.firmware, fld.split(".", 1)[1], value) else: setattr(target, fld, value) print(f" gain override: {side}.{joint}.{fld} = {value}") @@ -526,7 +549,7 @@ async def _run(args: argparse.Namespace) -> None: config.controller = args.controller print( f" controller: {config.controller} " - f"({config.loop_hz:.0f} Hz core loop" + f"({(args.loop_hz or config.loop_hz):.0f} Hz core loop" + ( ", every joint on its firmware position loop)" if config.controller == "position" @@ -652,7 +675,9 @@ async def execute( arm_channels["left_channel"] = None elif args.arms == "left": arm_channels["right_channel"] = None - robot = Axol(config=config, record=args.record, **arm_channels) + robot = Axol( + config=config, record=args.record, loop_hz=args.loop_hz, **arm_channels + ) async with robot as axol: contact: tuple[str, float] | None = None @@ -803,6 +828,7 @@ async def execute( # The control law the whole run ran on (impedance at 240 Hz # or the firmware position loops at 400 Hz). "controller": config.controller, + "loop_hz": args.loop_hz or config.loop_hz, "record": args.record, **stream_info, }, diff --git a/almond_axol/motor/damiao.py b/almond_axol/motor/damiao.py index 8bd5a147..d2b0c32e 100644 --- a/almond_axol/motor/damiao.py +++ b/almond_axol/motor/damiao.py @@ -551,34 +551,49 @@ async def ensure_rom_gains( """Bring the named loop gains to ``wanted`` in flash. ``wanted`` maps ``position_kp`` / ``position_ki`` / ``speed_kp`` / - ``speed_ki`` (KP_APR / KI_APR / KP_ASR / KI_ASR) to values. Each is - read first and written only when it differs beyond float32 rounding; - one 0xAA store follows if anything changed. Writes take effect at - once, so no reset is needed. Returns ``{name: (before, after)}``. + ``speed_ki`` (KP_APR / KI_APR / KP_ASR / KI_ASR) to values, plus + ``profile_acc``, the position-velocity profiler ramp written to ACC + and, negated, to DEC. Each is read first and written only when it + differs beyond float32 rounding; one 0xAA store follows if anything + changed. Writes take effect at once, so no reset is needed. Returns + ``{name: (before, after)}`` (``profile_acc`` reports the ACC side). """ regs = { "speed_kp": _DM_REG_SPEED_KP, "speed_ki": _DM_REG_SPEED_KI, "position_kp": _DM_REG_POS_KP, "position_ki": _DM_REG_POS_KI, + "profile_acc": _DM_REG_ACC, } unknown = set(wanted) - set(regs) if unknown: raise ValueError(f"Damiao loop has no gain(s) {sorted(unknown)}") changed: dict[str, tuple[float, float]] = {} for name, value in wanted.items(): - before = float(await self._read_register(regs[name])) - if abs(before - value) <= 1e-6 * max(1.0, abs(value)): + # The profiler ramp is one knob over two registers: ACC = +v, + # DEC = -v (the firmware wants the deceleration negative). + targets = ( + [(_DM_REG_ACC, float(value)), (_DM_REG_DEC, -abs(float(value)))] + if name == "profile_acc" + else [(regs[name], float(value))] + ) + before = float(await self._read_register(targets[0][0])) + matches = abs(before - targets[0][1]) <= 1e-6 * max(1.0, abs(targets[0][1])) + for rid, v in targets[1:]: + have = float(await self._read_register(rid)) + matches = matches and abs(have - v) <= 1e-6 * max(1.0, abs(v)) + if matches: continue - await self._write_register(regs[name], float(value)) - await asyncio.sleep(0.02) - after = float(await self._read_register(regs[name])) - if abs(after - value) > 1e-6 * max(1.0, abs(value)): - raise MotorError( - f"Damiao motor {self._motor_id:#04x}: wrote {name}={value:g} but " - f"reads back {after:g}" - ) - changed[name] = (before, after) + for rid, v in targets: + await self._write_register(rid, v) + await asyncio.sleep(0.02) + after = float(await self._read_register(rid)) + if abs(after - v) > 1e-6 * max(1.0, abs(v)): + raise MotorError( + f"Damiao motor {self._motor_id:#04x}: wrote {name}={v:g} " + f"(register {rid}) but reads back {after:g}" + ) + changed[name] = (before, targets[0][1]) if changed: await self._store_parameters() await asyncio.sleep(0.3) diff --git a/almond_axol/robot/config.py b/almond_axol/robot/config.py index 98892355..7600ca61 100644 --- a/almond_axol/robot/config.py +++ b/almond_axol/robot/config.py @@ -115,6 +115,18 @@ class FirmwareGains: joint: the integrator winds up while the joint is stuck and dumps it at release, so the stock 1e-4 feeds the surge (2e-4 limit-cycled at 5 Hz); 1e-5 halves the mode's current. + profile_acc: **Damiao only.** The position-velocity mode's profiler + ramp, rad/s² — written to both ACC and (negated) DEC. Every + streamed target is reached along a trapezoid under this + acceleration, so it caps how fast the wrist can follow: the + wrists ship at 2 rad/s² (115 deg/s²), which cannot keep up + with a 200 Hz stream — the loop hunts at ~5 Hz, 3x the + impedance frame's 3-15 Hz error — and would take half a + second to reach teleop speed. 50 is above the core + tracker's 33 rad/s² limit (the profile never binds) while + still rounding each 5 ms step; 50 and 200 scored alike in + ``tune.a4``. A MyActuator joint has no such register + (its planner acceleration is 0, direct tracking). """ position_kp: float | None = None @@ -122,6 +134,7 @@ class FirmwareGains: position_kd: float | None = None speed_kp: float | None = None speed_ki: float | None = None + profile_acc: float | None = None def as_dict(self) -> dict[str, float]: """The set gains, keyed by their MyActuator parameter name.""" @@ -411,7 +424,7 @@ class PositionForceConfig: # abort). The velocity-loop gains (KP_ASR 0.0037, KI_ASR 0.002) and the # profiler ramps changed nothing at 12 deg/s and stay stock; there is no kd. # Registers take effect on write and are stored — no reset (2026-09-22). -_DM_WRIST_FIRMWARE_GAINS = FirmwareGains(position_kp=400.0) +_DM_WRIST_FIRMWARE_GAINS = FirmwareGains(position_kp=400.0, profile_acc=50.0) _X6_ROLL_FIRMWARE_GAINS = FirmwareGains( position_kp=1.0, diff --git a/docs/cli/tune-motion.mdx b/docs/cli/tune-motion.mdx index 961460a2..a0067a4d 100644 --- a/docs/cli/tune-motion.mdx +++ b/docs/cli/tune-motion.mdx @@ -12,7 +12,7 @@ The arm moves to the motion's start and back to rest on collision-aware planned | Flag | Description | |---|---| | `--motion NAME` | Committed motion name (`axol motion.list`) or a path to a motion `.npz` (required) | -| `--gain [SIDE.]JOINT.FIELD=VALUE` | Override one gain for this run, e.g. `left.elbow.kd=4.5` or `shoulder_3.kd_host=8` (no side = both arms). Fields: `kp`, `kd`, `kd_host`, `kd_host_hz`, `kd_host_q`, `j_eff`, `stiction_gain`, `stiction_load_gain`, `stiction_err_deg`, `dither_nm`, `dither_hz`, `stribeck_gain`, `stribeck_dfs`, `stribeck_load_gain`, `stribeck_vs`, `stribeck_pole`, and the friction model as `friction.fc`, `friction.k`, `friction.fv`, `friction.fo`, `friction.fl`. Repeatable | +| `--gain [SIDE.]JOINT.FIELD=VALUE` | Override one gain for this run, e.g. `left.elbow.kd=4.5` or `shoulder_3.kd_host=8` (no side = both arms). Fields: `kp`, `kd`, `kd_host`, `kd_host_hz`, `kd_host_q`, `j_eff`, `stiction_gain`, `stiction_load_gain`, `stiction_err_deg`, `dither_nm`, `dither_hz`, `stribeck_gain`, `stribeck_dfs`, `stribeck_load_gain`, `stribeck_vs`, `stribeck_pole`, the friction model as `friction.fc`, `friction.k`, `friction.fv`, `friction.fo`, `friction.fl`, and the firmware position-loop gains as `firmware.position_kp`, `firmware.position_ki`, `firmware.position_kd`, `firmware.speed_kp`, `firmware.speed_ki`, `firmware.profile_acc` (written to the motors' ROM at enable like the config values they replace, so a run leaves them there). Repeatable | | `--stiffness S` | Stiffness-slider position in `[0, 1]` for both arms (default: 1.0, the production default — the tuned gains, where gain overrides land exactly; lower only adds compliance) | | `--ik` | Drive the run through the IK solver: each waypoint's end-effector poses (FK of the reference, with elbow hints) are re-solved to joints exactly like teleop's pose→joints loop, and the arms execute the *solver's* output — still scored against the clean reference, so IK reconstruction error and tracking error show up together. Per-solve times and the solved-vs-reference deviation are stored on the run (`ik_solve_ms_*`, `ik_dev_*`) | | `--noise MODE` | Corrupt the motion before streaming it, at the noise source's real pipeline entry point: `network` (jitter/outliers/stalls), `ik` (solver churn/jumps), `combined`, or `none` (default). Deterministic per `--seed` | @@ -22,6 +22,8 @@ The arm moves to the motion's start and back to rest on collision-aware planned | `--torque-threshold NM` | Contact watchdog threshold (default: 8.0; 0 disables) | | `--no-save-run` | Don't persist the run artifact (dry run) | | `--a4 SIDE.JOINT` | Drive one MyActuator joint with the firmware position loop (0xA4) instead of the MIT frame for this run, e.g. `right.shoulder_1`; repeatable. No compliance, no host feed-forward, NaN torque on that joint — see `wire_mode` in the [config reference](/cli/configuration) | +| `--loop-hz HZ` | Realtime-core tick rate override for A/B runs (default follows `--controller`: 240 impedance, 400 position) — e.g. `--controller position --loop-hz 240` to separate the rate from the controller | +| `--record PREFIX` | Flight recorder, as teleop's `--teleop.record`: the replay's measured joints to `PREFIX_meas.npz` and the core's per-tick trace (target, command, measured position, motor speed, feed-forward terms) to `PREFIX_rt.npz` | | `--controller impedance\|position` | Which control law the core runs the arms on for this run: `impedance` (the config default) is the production MIT frame at 240 Hz with the host feed-forward; `position` puts every joint on its motor's own position loop — MyActuator 0xA4, Damiao position-velocity, the `firmware.*` gains — streamed at 400 Hz. Stiff, no host feed-forward, NaN torque on the MyActuator joints. Same motion and scoring, so the two controllers compare directly; `--a4` still adds single joints inside the impedance controller. See `controller` in the [config reference](/cli/configuration) | | `--arms both\|left\|right` | Which arm(s) to bring up and drive (default: `both`). The other arm's bus is left untouched, so an unpowered arm or a single-arm bench does not block the run | | `--no-gripper` | Run on the gripperless SKU | diff --git a/docs/snippets/config/robot.mdx b/docs/snippets/config/robot.mdx index 70a38d32..d1acfb52 100644 --- a/docs/snippets/config/robot.mdx +++ b/docs/snippets/config/robot.mdx @@ -37,6 +37,7 @@ export const F = ({ children }) => {child | --{prefix}left.elbow.firmware.position_ki | `null` | Firmware position-loop integral gain. Leave at the stock `0`: on top of the speed integrator it hunts around the target. | | --{prefix}left.elbow.firmware.position_kd | `null` (shoulders and elbow `0.1`, shoulder_3 and wrist_1 `0.5`) | Firmware position-loop derivative gain (protocol V4.2+). The motor stores it, but the 0xA4 loop on the X8-P20 measured inert to it. | | --{prefix}left.elbow.firmware.speed_kp | `null` (shoulders `0.07`, shoulder_3, wrist_1 and elbow `0.05`) | Firmware speed-loop proportional gain. Not a damper in practice — it did nothing for the loop's 5 Hz reversal mode — but the knob on the speed loop's own resonance (~100 Hz on the X8-P20, ~135 Hz on the X6-P20): the current tone grows with it until the loop goes unstable (0.2 on the shoulder, 0.1 at position_kp 1.5 on the elbow). Lower is quieter at no tracking cost. | +| --{prefix}left.elbow.firmware.profile_acc | `null` (Damiao wrists `50`) | **Damiao wrists only:** the position-velocity profiler's ramp (rad/s²), written to ACC and, negated, DEC at enable. Every streamed target is approached along a trapezoid under this acceleration, so it caps how fast the wrist follows: the stock `2` (115 deg/s²) cannot keep up with the 200 Hz stream — the loop hunts at ~5 Hz — and would take half a second to reach teleop speed. `50` sits above the core tracker's 33 rad/s² limit while still rounding each 5 ms step; `50` and `200` scored alike in `tune.a4`. | | --{prefix}left.elbow.firmware.speed_ki | `null` (every MyActuator joint `1e-05`) | Firmware speed-loop integral gain. Lower than the stock `1e-4` is smoother on a geared joint: the integrator winds up while the joint is stuck and dumps it at release. | Per-arm and per-joint flags are shown for `left` / `elbow`. Swap in `right`, or any joint: `shoulder_1`, `shoulder_2`, `shoulder_3`, `elbow`, `wrist_1`, `wrist_2`, `wrist_3`. diff --git a/tests/test_controller_option.py b/tests/test_controller_option.py index 38702819..6703aaaa 100644 --- a/tests/test_controller_option.py +++ b/tests/test_controller_option.py @@ -158,6 +158,24 @@ def _parse(self, *argv: str) -> argparse.Namespace: tune_motion.add_parser(sub) return parser.parse_args(["tune.motion", "--motion", "slow_osc", *argv]) + def test_loop_hz_override_and_firmware_gain_overrides_parse(self) -> None: + ns = self._parse( + "--loop-hz", "240", "--gain", "right.elbow.firmware.speed_kp=0.03" + ) + self.assertEqual(ns.loop_hz, 240.0) + overrides = tune_motion._parse_gain_overrides(ns.gain) + self.assertEqual(overrides, {("right", "elbow", "firmware.speed_kp"): 0.03}) + both = tune_motion._parse_gain_overrides(["wrist_2.firmware.profile_acc=200"]) + self.assertEqual( + both, + { + ("left", "wrist_2", "firmware.profile_acc"): 200.0, + ("right", "wrist_2", "firmware.profile_acc"): 200.0, + }, + ) + with self.assertRaises(SystemExit): + tune_motion._parse_gain_overrides(["elbow.firmware.bogus=1"]) + def test_controller_flag_takes_the_two_laws_and_defaults_to_config(self) -> None: self.assertIsNone(self._parse().controller) self.assertEqual(self._parse("--controller", "position").controller, "position") diff --git a/tests/test_firmware_gains.py b/tests/test_firmware_gains.py index fbf37e7f..0f738392 100644 --- a/tests/test_firmware_gains.py +++ b/tests/test_firmware_gains.py @@ -60,7 +60,8 @@ def test_damiao_wrists_carry_the_position_gain_only(self) -> None: for arm in (cfg.left, cfg.right): for name in ("wrist_2", "wrist_3"): self.assertEqual( - getattr(arm, name).firmware.as_dict(), {"position_kp": 400.0} + getattr(arm, name).firmware.as_dict(), + {"position_kp": 400.0, "profile_acc": 50.0}, ) def test_the_gripper_has_no_firmware_block(self) -> None: @@ -274,7 +275,7 @@ async def test_gripper_is_skipped(self) -> None: async def test_damiao_wrist_is_provisioned_through_its_registers_without_a_reset( self, ) -> None: - w2 = _FakeDamiao({25: 0.0037, 26: 0.002, 27: 54.0, 28: 0.0}) + w2 = _FakeDamiao({25: 0.0037, 26: 0.002, 27: 54.0, 28: 0.0, 4: 50.0, 5: -50.0}) arm = _arm({Joint.WRIST_2: w2}) with self.assertLogs("almond_axol.robot.axol", level="INFO") as logs: await apply_firmware_gains(arm, [Joint.WRIST_2]) @@ -287,6 +288,32 @@ async def test_damiao_wrist_is_provisioned_through_its_registers_without_a_reset await apply_firmware_gains(arm, [Joint.WRIST_2]) self.assertEqual((w2.writes, w2.stores), ([], 0)) + async def test_damiao_profile_ramp_writes_acc_and_negative_dec(self) -> None: + # Stock wrists: KP_APR 54, ramps ±2 rad/s². Config wants 400 and 50. + w2 = _FakeDamiao({25: 0.0037, 26: 0.002, 27: 54.0, 28: 0.0, 4: 2.0, 5: -2.0}) + arm = _arm({Joint.WRIST_2: w2}) + with self.assertLogs("almond_axol.robot.axol", level="INFO"): + await apply_firmware_gains(arm, [Joint.WRIST_2]) + self.assertEqual((w2.store[27], w2.store[4], w2.store[5]), (400.0, 50.0, -50.0)) + self.assertEqual(w2.stores, 1) + w2.writes.clear() + w2.stores = 0 + await apply_firmware_gains(arm, [Joint.WRIST_2]) + self.assertEqual((w2.writes, w2.stores), ([], 0)) + # A DEC that drifted alone is repaired too. + w2.store[5] = -2.0 + await apply_firmware_gains(arm, [Joint.WRIST_2]) + self.assertEqual(w2.store[5], -50.0) + self.assertEqual(w2.stores, 1) + + def test_wrists_carry_the_profile_ramp_and_myactuator_joints_do_not(self) -> None: + cfg = AxolConfig() + for arm in (cfg.left, cfg.right): + self.assertEqual(arm.wrist_2.firmware.profile_acc, 50.0) + self.assertEqual(arm.wrist_3.firmware.profile_acc, 50.0) + self.assertIsNone(arm.elbow.firmware.profile_acc) + self.assertIsNone(arm.shoulder_1.firmware.profile_acc) + async def test_configured_gains_on_a_joint_without_a_loop_warn(self) -> None: arm = _arm({Joint.WRIST_2: object()}) # a driver of neither vendor arm._arm_config = replace( From 7c065e411e8a8a8bbc064e79392910a40b547db6 Mon Sep 17 00:00:00 2001 From: Shawn Patel Date: Tue, 22 Sep 2026 20:14:19 +0000 Subject: [PATCH 41/80] tune.motion: re-time cache reads onto the command clock before scoring The core refreshes the feedback caches at its tick rate and the replay reads them at the motion rate; the sawtooth in that age showed up as an 80 Hz 'buzz' on every joint once the core ran at 400 Hz. Each joint's sample is put back at its feedback timestamp and interpolated onto the log grid. Co-Authored-By: Claude Fable 5.1 --- almond_axol/cli/tune/motion.py | 63 +++++++++++++++++++++++++++++++++ tests/test_tune_motion_start.py | 35 ++++++++++++++++++ 2 files changed, 98 insertions(+) diff --git a/almond_axol/cli/tune/motion.py b/almond_axol/cli/tune/motion.py index e7e0d618..4d64cdd6 100644 --- a/almond_axol/cli/tune/motion.py +++ b/almond_axol/cli/tune/motion.py @@ -47,6 +47,7 @@ import logging import math import time +from typing import Any import numpy as np @@ -107,6 +108,46 @@ _START_POSE_TOL = 0.05 +def retime_measurements( + t: np.ndarray, offsets: np.ndarray, actual: np.ndarray, torque: np.ndarray +) -> tuple[np.ndarray, np.ndarray]: + """Put cache reads back on the command clock. + + ``offsets[k, i]`` is how long before log time ``t[k]`` joint ``i``'s + cached sample was really taken (≤ 0; ``0`` when unknown). Each column + is interpolated from its true sample times ``t + offsets`` back onto + ``t``, so the scorecard compares the target with the measurement at the + same instant instead of with a sample up to one core tick old — the + sawtooth in that age is what a 400 Hz core read at 240 Hz shows as an + 80 Hz buzz on every joint. Duplicate samples (the same tick read twice) + collapse to one point; NaN columns (absent arm) pass through. + """ + if len(t) < 2 or offsets.shape != actual.shape: + return actual, torque + out_a = actual.copy() + out_q = torque.copy() + for i in range(actual.shape[1]): + col = actual[:, i] + if not np.any(np.isfinite(col)) or not np.any(offsets[:, i] != 0.0): + continue + ts = t + offsets[:, i] + keep = np.concatenate([[True], np.diff(ts) > 0]) + keep &= np.isfinite(col) + if keep.sum() < 2: + continue + out_a[:, i] = np.interp( + t, ts[keep], col[keep], left=col[keep][0], right=col[keep][-1] + ) + tq = torque[:, i] + if np.any(np.isfinite(tq)): + kq = keep & np.isfinite(tq) + if kq.sum() >= 2: + out_q[:, i] = np.interp( + t, ts[kq], tq[kq], left=tq[kq][0], right=tq[kq][-1] + ) + return out_a, out_q + + def start_pose_stragglers( q_now: np.ndarray, q_start: np.ndarray, @@ -604,6 +645,22 @@ def snapshot(axol: Axol) -> np.ndarray: log_sent: list[np.ndarray] = [] log_actual: list[np.ndarray] = [] log_torque: list[np.ndarray] = [] + # When each measured sample was actually taken on the wire (seconds + # relative to the sample's own log time, ≤ 0): the core refreshes the + # caches at its tick rate, this loop reads them at the motion rate, and + # the varying cache age between the two clocks is a sawtooth that a + # 400 Hz core sampled at 240 Hz turns into an 80 Hz "buzz" on every + # joint. Re-timing each sample removes it. + log_meas_offset: list[np.ndarray] = [] + + def _feedback_offsets(arm: Any, now_wall: float) -> np.ndarray: + out = np.zeros(7, dtype=np.float64) + for i, j in enumerate(ARM_JOINTS): + motor = arm.motors.get(j) + ts = getattr(motor, "_feedback_ts", None) if motor is not None else None + if ts is not None: + out[i] = min(0.0, ts - now_wall) + return out async def execute( axol: Axol, @@ -637,13 +694,18 @@ async def execute( if record: row_a = np.full(14, np.nan, dtype=np.float32) row_tq = np.full(14, np.nan, dtype=np.float32) + row_off = np.zeros(14, dtype=np.float64) + now_wall = time.time() if axol.left is not None: row_a[:7] = axol.left.positions[:7] row_tq[:7] = axol.left.torques[:7] + row_off[:7] = _feedback_offsets(axol.left, now_wall) if axol.right is not None: row_a[7:] = axol.right.positions[:7] row_tq[7:] = axol.right.torques[:7] + row_off[7:] = _feedback_offsets(axol.right, now_wall) log_t.append(time.perf_counter() - t0) + log_meas_offset.append(row_off) row_cmd = np.concatenate( [q[solver.left_indices], q[solver.right_indices]] ).astype(np.float32) @@ -766,6 +828,7 @@ async def execute( target = np.stack(log_target) actual = np.stack(log_actual) torque = np.stack(log_torque) + actual, torque = retime_measurements(t, np.stack(log_meas_offset), actual, torque) # Tracking quality is only scored for joints that actually moved (> ~1° # of commanded travel) — a joint parked at rest tracks meaninglessly diff --git a/tests/test_tune_motion_start.py b/tests/test_tune_motion_start.py index 182045d2..e190c2b7 100644 --- a/tests/test_tune_motion_start.py +++ b/tests/test_tune_motion_start.py @@ -44,3 +44,38 @@ def test_an_arm_left_off_is_not_judged(self) -> None: if __name__ == "__main__": unittest.main() + + +class RetimeMeasurementsTest(unittest.TestCase): + """Cache reads are put back on the command clock before scoring.""" + + def test_a_sawtooth_cache_age_is_removed(self) -> None: + from almond_axol.cli.tune.motion import retime_measurements + + # A 240 Hz log of a joint moving at 1 rad/s whose cache was refreshed + # at 400 Hz: each sample is 0..2.5 ms old, in a repeating pattern. + t = np.arange(0, 1.0, 1 / 240) + age = (np.arange(len(t)) % 5) * 0.0005 + offsets = np.zeros((len(t), 14)) + offsets[:, 7] = -age + actual = np.full((len(t), 14), np.nan, dtype=np.float32) + actual[:, 7] = (t - age).astype(np.float32) # the position when sampled + torque = np.full_like(actual, np.nan) + fixed, _ = retime_measurements(t, offsets, actual, torque) + # Raw error against the command clock is the sawtooth; re-timed it is ~0. + raw = np.sqrt(np.mean((actual[:, 7] - t) ** 2)) + new = np.sqrt(np.mean((fixed[5:-5, 7] - t[5:-5]) ** 2)) + self.assertGreater(raw, 5e-4) + self.assertLess(new, 2e-5) + # An absent arm's NaN columns and unknown offsets pass through untouched. + self.assertTrue(np.all(np.isnan(fixed[:, 0]))) + + def test_unknown_offsets_leave_the_series_alone(self) -> None: + from almond_axol.cli.tune.motion import retime_measurements + + t = np.arange(0, 0.1, 1 / 240) + actual = np.random.default_rng(0).normal(size=(len(t), 14)).astype(np.float32) + torque = actual.copy() + a, q = retime_measurements(t, np.zeros((len(t), 14)), actual, torque) + np.testing.assert_array_equal(a, actual) + np.testing.assert_array_equal(q, torque) From 15e2b6aa61081d6c4dd45d634e66a7dd82441299 Mon Sep 17 00:00:00 2001 From: Shawn Patel Date: Tue, 22 Sep 2026 20:24:48 +0000 Subject: [PATCH 42/80] Position controller: MyActuator joints on 0xA4, Damiao wrists stay on impedance (pv is a per-wrist opt-in) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Measured on slow_osc (right arm, flight-recorder traces): the wrists' pv loop stick-slips at creep speed (3-15 Hz error 0.83/0.73 mrad vs 0.27/0.35 on the impedance frame) and its stiff hold pumps the extended arm's 4.3 Hz sway (both shoulders 0.27° p2p and wrist_3 0.68° during wrist sweeps, absent with the wrists' old soft profiler). With the wrists on impedance the MyActuator joints keep their position-controller gains (3-15 Hz error halved or better vs impedance), no hold ring, bus 71-73% busy at 400 Hz. Co-Authored-By: Claude Fable 5.1 --- almond_axol/cli/tune/motion.py | 12 +++--- almond_axol/robot/config.py | 40 +++++++++++-------- almond_axol/rt/robot.py | 8 ++-- docs/cli/teleop.mdx | 2 +- docs/cli/tune-motion.mdx | 2 +- docs/guides/diagnostics-dashboard.mdx | 2 +- docs/snippets/config/robot.mdx | 2 +- tests/test_controller_option.py | 18 +++++++-- .../diagnostics/tuning-workbench.tsx | 20 +++++----- 9 files changed, 62 insertions(+), 44 deletions(-) diff --git a/almond_axol/cli/tune/motion.py b/almond_axol/cli/tune/motion.py index 4d64cdd6..0a214e6b 100644 --- a/almond_axol/cli/tune/motion.py +++ b/almond_axol/cli/tune/motion.py @@ -335,11 +335,11 @@ def add_parser(subparsers: argparse._SubParsersAction) -> None: # type: ignore[ default=None, help="Which control law the core runs the arms on for this run. " "'impedance' (the config default) is the production MIT frame at 240 Hz " - "with the host feedforward; 'position' puts every joint on its motor's " - "own position loop (MyActuator 0xA4, Damiao position-velocity; the " - "firmware.* gains) streamed at 400 Hz — stiff, no host feedforward, " - "NaN torque on the MyActuator joints. --a4 still adds single joints " - "inside the impedance controller.", + "with the host feedforward; 'position' puts the MyActuator joints on " + "their motor's own 0xA4 position loop (the firmware.* gains) streamed at " + "400 Hz — stiff, no host feedforward, NaN torque on those joints — with " + "the Damiao wrists staying on impedance (a wrist's wire_mode pv opts it " + "in). --a4 still adds single joints inside the impedance controller.", ) p.add_argument( "--arms", @@ -592,7 +592,7 @@ async def _run(args: argparse.Namespace) -> None: f" controller: {config.controller} " f"({(args.loop_hz or config.loop_hz):.0f} Hz core loop" + ( - ", every joint on its firmware position loop)" + ", MyActuator joints on their firmware position loop)" if config.controller == "position" else ")" ) diff --git a/almond_axol/robot/config.py b/almond_axol/robot/config.py index 7600ca61..95b42948 100644 --- a/almond_axol/robot/config.py +++ b/almond_axol/robot/config.py @@ -917,12 +917,16 @@ def _apply_stiffness(arm: ArmConfig, s: float | Sequence[float]) -> ArmConfig: #: #: ``"impedance"`` is the production MIT frame: host gravity / friction / #: inertia feedforward and host damping around the firmware PD, compliant, -#: at 240 Hz. ``"position"`` hands every joint to its motor's own position -#: loop — 0xA4 on the MyActuator joints, position-velocity on the Damiao -#: wrists, gains from each joint's ``firmware`` block — streamed at 400 Hz, -#: where the loop's target staircase (audible at 200 Hz) is gone. It is -#: stiff: no compliance, no host feedforward, the contact watchdog blind on -#: the MyActuator joints. The bus cannot carry every motor every tick at +#: at 240 Hz. ``"position"`` hands the five MyActuator joints to their +#: motor's own position loop (0xA4, gains from each joint's ``firmware`` +#: block) streamed at 400 Hz, where the loop's target staircase (audible at +#: 200 Hz) is gone. They are stiff: no compliance, no host feedforward, the +#: contact watchdog blind on them. The Damiao wrists stay on the impedance +#: frame: their position-velocity loop (``wire_mode`` ``pv``) stick-slips at +#: creep speed (3x the impedance frame's 3-15 Hz error at 4 deg/s) and its +#: stiff hold pumps the extended arm's 4.3 Hz sway (2026-09-22, both +#: shoulders at 0.27° p2p in the wrist-sweep pose), so it stays a per-joint +#: opt-in for experiments. The bus cannot carry every motor every tick at #: 400 Hz, so the core thins its schedule (wrists commanded on alternate #: ticks, one a4 fine-position read per tick round-robin, gripper in that #: rotation); the MyActuator commands themselves go out every tick. @@ -942,12 +946,13 @@ def position_wire_mode(joint: Joint) -> str: def _on_position_loops(arm: ArmConfig) -> ArmConfig: - """Every arm joint on its vendor's firmware position loop.""" + """The MyActuator joints on 0xA4; the Damiao wrists as configured.""" return replace( arm, **{ - j.value: replace(getattr(arm, j.value), wire_mode=position_wire_mode(j)) + j.value: replace(getattr(arm, j.value), wire_mode="a4") for j in ARM_JOINTS + if _JOINT_CONFIG[j].motor_id <= 5 }, ) @@ -1000,15 +1005,16 @@ class AxolConfig: controller: Which control law the realtime core runs the arms on — see :data:`CONTROLLERS`. ``"impedance"`` (default) is the production MIT frame at 240 Hz. - ``"position"`` puts every joint on its firmware - position loop (``wire_mode`` ``a4`` / ``pv``, the - ``firmware`` gains) at 400 Hz. Like stiffness it is - baked into the per-joint ``wire_mode`` fields by - :meth:`resolved`; a per-joint ``wire_mode`` set - explicitly under ``"impedance"`` is kept, so one - joint can still be tried on its firmware loop - inside the impedance controller (``tune.motion - --a4``). + ``"position"`` puts the MyActuator joints on their + firmware position loop (``wire_mode`` ``a4``, the + ``firmware`` gains) at 400 Hz; the Damiao wrists + keep their configured ``wire_mode`` (``mit`` unless + set to ``pv``). Like stiffness it is baked into the + per-joint ``wire_mode`` fields by :meth:`resolved`; + a per-joint ``wire_mode`` set explicitly under + ``"impedance"`` is kept, so one joint can still be + tried on its firmware loop inside the impedance + controller (``tune.motion --a4``). """ left: ArmConfig = field( diff --git a/almond_axol/rt/robot.py b/almond_axol/rt/robot.py index ae31aa51..4fb84eff 100644 --- a/almond_axol/rt/robot.py +++ b/almond_axol/rt/robot.py @@ -399,10 +399,10 @@ def _warn_wire_modes(self) -> None: controller = self._axol_config().controller if controller == "position": _logger.warning( - "rt: position controller — every joint on its firmware position " - "loop (a4 / pv) at %.0f Hz: no compliance, no host feedforward, " - "torque telemetry NaN on the MyActuator joints — the contact " - "watchdog cannot see them", + "rt: position controller — %s on the firmware position loop " + "at %.0f Hz: no compliance, no host feedforward, torque telemetry " + "NaN on the a4 joints — the contact watchdog cannot see them", + ", ".join(firmware), self._loop_hz, ) return diff --git a/docs/cli/teleop.mdx b/docs/cli/teleop.mdx index b750fd5c..058867a2 100644 --- a/docs/cli/teleop.mdx +++ b/docs/cli/teleop.mdx @@ -5,7 +5,7 @@ description: "Launch a VR teleoperation session on the robot or simulator." Launches a VR teleoperation session. When started, the hostname (`.local`) and local IP address are printed — enter either of these in the VR app at [axol.almond.bot](https://axol.almond.bot) to connect. -Runs on the **real robot by default**; pass `--sim` for the browser visualizer. Hardware control always uses the required `axol-rt` realtime core, which owns CAN — at 240 Hz on the default impedance controller, or at 400 Hz with `--axol.controller position`, which runs every joint on its motor's own firmware position loop (see `controller` in the [robot config](/cli/configuration)) — while Python handles VR, IK, and the slow model. The full robot config is exposed via draccus, so every gain, stiffness, and gripper limit is reachable from the CLI or a config file — see [Command configuration](/cli/configuration) for the syntax. The table below covers the commonly used flags; every field of the nested `axol`, `teleop`, `kinematics`, `vr_server`, and `cart` configs is listed with its default in [Teleoperation Config Parameters](/operations/teleop-config). +Runs on the **real robot by default**; pass `--sim` for the browser visualizer. Hardware control always uses the required `axol-rt` realtime core, which owns CAN — at 240 Hz on the default impedance controller, or at 400 Hz with `--axol.controller position`, which runs the MyActuator joints on their motor's own firmware position loop (see `controller` in the [robot config](/cli/configuration)) — while Python handles VR, IK, and the slow model. The full robot config is exposed via draccus, so every gain, stiffness, and gripper limit is reachable from the CLI or a config file — see [Command configuration](/cli/configuration) for the syntax. The table below covers the commonly used flags; every field of the nested `axol`, `teleop`, `kinematics`, `vr_server`, and `cart` configs is listed with its default in [Teleoperation Config Parameters](/operations/teleop-config). For a guided, step-by-step walkthrough including the VR controller layout, see the [Teleoperation guide](/operations/teleop). diff --git a/docs/cli/tune-motion.mdx b/docs/cli/tune-motion.mdx index a0067a4d..fc6db677 100644 --- a/docs/cli/tune-motion.mdx +++ b/docs/cli/tune-motion.mdx @@ -24,7 +24,7 @@ The arm moves to the motion's start and back to rest on collision-aware planned | `--a4 SIDE.JOINT` | Drive one MyActuator joint with the firmware position loop (0xA4) instead of the MIT frame for this run, e.g. `right.shoulder_1`; repeatable. No compliance, no host feed-forward, NaN torque on that joint — see `wire_mode` in the [config reference](/cli/configuration) | | `--loop-hz HZ` | Realtime-core tick rate override for A/B runs (default follows `--controller`: 240 impedance, 400 position) — e.g. `--controller position --loop-hz 240` to separate the rate from the controller | | `--record PREFIX` | Flight recorder, as teleop's `--teleop.record`: the replay's measured joints to `PREFIX_meas.npz` and the core's per-tick trace (target, command, measured position, motor speed, feed-forward terms) to `PREFIX_rt.npz` | -| `--controller impedance\|position` | Which control law the core runs the arms on for this run: `impedance` (the config default) is the production MIT frame at 240 Hz with the host feed-forward; `position` puts every joint on its motor's own position loop — MyActuator 0xA4, Damiao position-velocity, the `firmware.*` gains — streamed at 400 Hz. Stiff, no host feed-forward, NaN torque on the MyActuator joints. Same motion and scoring, so the two controllers compare directly; `--a4` still adds single joints inside the impedance controller. See `controller` in the [config reference](/cli/configuration) | +| `--controller impedance\|position` | Which control law the core runs the arms on for this run: `impedance` (the config default) is the production MIT frame at 240 Hz with the host feed-forward; `position` puts the MyActuator joints on their motor's own 0xA4 position loop (the `firmware.*` gains) streamed at 400 Hz, the Damiao wrists staying on impedance unless a wrist's `wire_mode` is `pv`. Stiff, no host feed-forward, NaN torque on those joints. Same motion and scoring, so the two controllers compare directly; `--a4` still adds single joints inside the impedance controller. See `controller` in the [config reference](/cli/configuration) | | `--arms both\|left\|right` | Which arm(s) to bring up and drive (default: `both`). The other arm's bus is left untouched, so an unpowered arm or a single-arm bench does not block the run | | `--no-gripper` | Run on the gripperless SKU | diff --git a/docs/guides/diagnostics-dashboard.mdx b/docs/guides/diagnostics-dashboard.mdx index d0cadc3c..4408f647 100644 --- a/docs/guides/diagnostics-dashboard.mdx +++ b/docs/guides/diagnostics-dashboard.mdx @@ -61,7 +61,7 @@ Gain fields (kp, kd, kd_host, kd_host_hz, kd_host_q) show the selected joint's * - **Factory** — [`tune.factory`](https://docs.almond.bot/cli/tune-factory) runs friction + gravity for **all 14 joints** in one go (both arms, distal→proximal), saving every fit to this robot's calibration and uploading the document to the cloud keyed by the hub adapter serial when Supabase credentials are configured. This is the one-button factory calibration; expect a long run. To compare two runs, tick their checkboxes in the run list (same run kind — sine vs sine, motion vs motion). The view switches to an overlay: commanded plus both runs' outputs per joint (A yellow, B blue), both error traces in the lane, a verdict line naming the better run by its headline score, and an A-vs-B score table with the better value of each pair in green. Untick (or Exit compare) to go back to single-run view. -- **Recorded motion** — [`tune.motion`](https://docs.almond.bot/cli/tune-motion) replays a committed reference motion through the production Rust-core control path and scores tracking per joint. The gain table shows every joint's current config value (defaults + this robot's calibration) for each gain field; edit a cell to override it **for this run only, on both arms** — edited cells highlight, everything else runs with config. Clearing a cell drops the override and snaps it back to the config value (**reset overrides** clears them all). The **controller** select picks the control law for the whole run: **impedance** (the production MIT frame with the host feed-forward, 240 Hz) or **position** (`--controller position`: every joint on its motor's own firmware position loop — MyActuator 0xA4, Damiao position-velocity, the gains on the Firmware-loop tab — streamed at 400 Hz). The **controller per joint** grid below it picks, inside the impedance controller, per arm and MyActuator joint, whether that joint runs on **impedance** or on its **firmware** loop (`--a4 side.joint`) for this run — e.g. shoulder_1 on the firmware loop with everything else on impedance, scored against the identical motion; a joint the config already pins to `wire_mode a4` shows as firmware and cannot be switched back. Tick **run as IK** to drive the run through the IK solver instead of raw joint replay: the motion's Cartesian end-effector path (FK of the reference, with elbow hints) is re-solved to joints like teleop's pose→joints loop and the arms execute the solver's output — still scored against the clean reference, so IK reconstruction error and tracking show up together in the same charts. The tab also carries the filter-stack test on real hardware: pick a **noise** source (**network** = seeded jitter/outliers/stalls injected before the pose low-pass, **IK** = solver churn/jumps injected after it, or **combined** — each at its real entry point in the pipeline, deterministic per seed) and toggle the **filter stack** (pose low-pass → EMA → trapezoid). The run is scored against the clean reference either way, and the charts overlay **commanded (clean), sent (what was actually streamed), and actual** per joint — run once with the filters off and once on to see, on the physical arm, what the stack removes and what it costs in lag. +- **Recorded motion** — [`tune.motion`](https://docs.almond.bot/cli/tune-motion) replays a committed reference motion through the production Rust-core control path and scores tracking per joint. The gain table shows every joint's current config value (defaults + this robot's calibration) for each gain field; edit a cell to override it **for this run only, on both arms** — edited cells highlight, everything else runs with config. Clearing a cell drops the override and snaps it back to the config value (**reset overrides** clears them all). The **controller** select picks the control law for the whole run: **impedance** (the production MIT frame with the host feed-forward, 240 Hz) or **position** (`--controller position`: the five MyActuator joints on their motor's own 0xA4 position loop — the gains on the Firmware-loop tab — streamed at 400 Hz, the Damiao wrists staying on impedance). The **controller per joint** grid below it picks, inside the impedance controller, per arm and MyActuator joint, whether that joint runs on **impedance** or on its **firmware** loop (`--a4 side.joint`) for this run — e.g. shoulder_1 on the firmware loop with everything else on impedance, scored against the identical motion; a joint the config already pins to `wire_mode a4` shows as firmware and cannot be switched back. Tick **run as IK** to drive the run through the IK solver instead of raw joint replay: the motion's Cartesian end-effector path (FK of the reference, with elbow hints) is re-solved to joints like teleop's pose→joints loop and the arms execute the solver's output — still scored against the clean reference, so IK reconstruction error and tracking show up together in the same charts. The tab also carries the filter-stack test on real hardware: pick a **noise** source (**network** = seeded jitter/outliers/stalls injected before the pose low-pass, **IK** = solver churn/jumps injected after it, or **combined** — each at its real entry point in the pipeline, deterministic per seed) and toggle the **filter stack** (pose low-pass → EMA → trapezoid). The run is scored against the clean reference either way, and the charts overlay **commanded (clean), sent (what was actually streamed), and actual** per joint — run once with the filters off and once on to see, on the physical arm, what the stack removes and what it costs in lag. - **Build motion** — [`motion.build`](https://docs.almond.bot/cli/motion-build) turns a recorded session — teleoperated (`axol teleop --teleop.record`) or hand-guided in gravity comp (`axol gravity-comp --record`, also settable per run on the gravity-comp operation panel) — into a reference motion, which then shows up in the Recorded motion picker. Every build saves a **before/after run**: the raw recorded command stream vs the built motion per joint (with a built−recorded lane), so the smoothing pass and the collision projection are visually checkable — with the same zoom/fullscreen as every other chart — before the motion is replayed. - **IK** — [`diag.offline kinematics`](https://docs.almond.bot/cli/diag-offline) analyzes a teleop recording offline (no hardware, defaults to the newest recording): per-axis charts of the **EE pose set vs actual** (the world target given to the solver vs FK of what it solved) with an error lane in mm, the per-tick **solve time** trace (spikes here surface as teleop lurches), and a per-joint churn/jitter scorecard that catches a restless null space. diff --git a/docs/snippets/config/robot.mdx b/docs/snippets/config/robot.mdx index d1acfb52..c4c4b54b 100644 --- a/docs/snippets/config/robot.mdx +++ b/docs/snippets/config/robot.mdx @@ -6,7 +6,7 @@ export const F = ({ children }) => {child | --{prefix}right_stiffness | `1.0` | Same, for the right arm. | | --{prefix}has_gripper | `true` | Set `false` on the gripperless model. The gripper motors are never enabled and datasets record 7 channels per arm instead of 8. | | --{prefix}max_step_rad | `0.5` | Largest joint move (rad) allowed between two consecutive commands. Bigger moves are dropped with a warning. `inf` disables. | -| --{prefix}controller | `impedance` | Which control law the realtime core runs the arms on. `impedance` is the production MIT frame at **240 Hz**: host gravity / friction / inertia feed-forward and host damping around the firmware PD, compliant. `position` hands every joint to its motor's own position loop at **400 Hz** — 0xA4 on the MyActuator joints, position-velocity on the Damiao wrists, the `firmware.*` gains below (written to ROM at enable) — where the position loop's target staircase (audible at 200 Hz) is gone. It is stiff: no compliance, no host feed-forward, and the contact watchdog is blind on the MyActuator joints (their torque telemetry reads NaN). At 400 Hz the bus cannot carry every motor every tick, so the core thins its schedule: every MyActuator command still goes out every tick, the two wrists are commanded on alternate ticks (200 Hz each), and one 0.01° a4 position read per tick rotates through the a4 joints and the gripper (~67 Hz each; between reads a joint's position is carried on the speed its command echo reports every tick). The choice is baked into each joint's `wire_mode` when the robot is constructed; under `impedance` a per-joint `wire_mode` set explicitly is kept. Recorded-motion replays take `--controller` ([`tune.motion`](/cli/tune-motion)); teleop takes `--axol.controller position`. | +| --{prefix}controller | `impedance` | Which control law the realtime core runs the arms on. `impedance` is the production MIT frame at **240 Hz**: host gravity / friction / inertia feed-forward and host damping around the firmware PD, compliant. `position` hands the five **MyActuator** joints to their motor's own 0xA4 position loop at **400 Hz** (the `firmware.*` gains below, written to ROM at enable), where the position loop's target staircase (audible at 200 Hz) is gone; those joints are stiff — no compliance, no host feed-forward, and the contact watchdog is blind on them (their torque telemetry reads NaN). The Damiao wrists stay on the impedance frame: their position-velocity loop (`wire_mode` `pv`, opt-in per wrist) stick-slips at creep speed (3× the impedance frame's 3-15 Hz error at 4 deg/s) and its stiff hold pumps the extended arm's 4.3 Hz sway (both shoulders at 0.27° p2p in the wrist-sweep pose, 2026-09-22). At 400 Hz the bus cannot carry every motor every tick, so the core thins its schedule: every MyActuator command still goes out every tick, the two wrists are commanded on alternate ticks (200 Hz each), and one 0.01° a4 position read per tick rotates through the a4 joints and the gripper (~67 Hz each; between reads a joint's position is carried on the speed its command echo reports every tick). Measured on `slow_osc`: the MyActuator joints' 3-15 Hz tracking error halves or better against impedance (shoulder_1 0.23 vs 0.50 mrad, elbow 0.10 vs 0.74), at ~30 ms more lag (the firmware loop's own) and with the motor's 20-cycles-per-rev torque ripple audible as a 65-90 Hz tone above ~50 deg/s (3-6 deg/s of speed ripple, where impedance shows ~2 at that line plus 4-5 at its 32-45 Hz structural mode; the speed-loop gain does not move it). The choice is baked into each joint's `wire_mode` when the robot is constructed; under `impedance` a per-joint `wire_mode` set explicitly is kept. Recorded-motion replays take `--controller` ([`tune.motion`](/cli/tune-motion)); teleop takes `--axol.controller position`. | | --{prefix}left.gripper.torque_limit | `0.5` | Grip force — peak gripper torque (Nm). | | --{prefix}left.gripper.max_speed | `10.0` | Maximum gripper speed (rad/s). | | --{prefix}left.elbow.kp | per joint | Position stiffness of one joint, in `[0, 500]`. This is the stiffness-`1` value. | diff --git a/tests/test_controller_option.py b/tests/test_controller_option.py index 6703aaaa..aeb072d3 100644 --- a/tests/test_controller_option.py +++ b/tests/test_controller_option.py @@ -41,7 +41,7 @@ def test_impedance_is_the_default_and_leaves_wire_modes_alone(self) -> None: for j in ARM_JOINTS: self.assertEqual(getattr(arm, j.value).wire_mode, "mit") - def test_position_puts_every_joint_on_its_vendors_loop_at_400_hz(self) -> None: + def test_position_puts_the_myactuator_joints_on_a4_at_400_hz(self) -> None: cfg = AxolConfig(controller="position") self.assertEqual(cfg.loop_hz, 400.0) resolved = cfg.resolved() @@ -49,11 +49,21 @@ def test_position_puts_every_joint_on_its_vendors_loop_at_400_hz(self) -> None: for arm in (resolved.left, resolved.right): for j in _MYACTUATOR: self.assertEqual(getattr(arm, j.value).wire_mode, "a4", j) + # The Damiao wrists keep the impedance frame (their pv loop + # stick-slips at creep and pumps the arm's 4 Hz sway). for j in _DAMIAO: - self.assertEqual(getattr(arm, j.value).wire_mode, "pv", j) + self.assertEqual(getattr(arm, j.value).wire_mode, "mit", j) # Idempotent, like the stiffness blend. self.assertEqual(resolved.resolved(), resolved) + def test_a_wrist_opted_into_pv_survives_the_position_controller(self) -> None: + cfg = AxolConfig(controller="position") + cfg.right.wrist_2.wire_mode = "pv" + resolved = cfg.resolved() + self.assertEqual(resolved.right.wrist_2.wire_mode, "pv") + self.assertEqual(resolved.right.wrist_3.wire_mode, "mit") + self.assertEqual(resolved.right.elbow.wire_mode, "a4") + def test_position_wire_mode_follows_the_motor_vendor(self) -> None: for j in _MYACTUATOR: self.assertEqual(position_wire_mode(j), "a4") @@ -124,7 +134,7 @@ def test_impedance_core_runs_at_240_on_mit(self) -> None: self.assertIn("loop_hz 240.0", lines) self.assertEqual(set(self._joint_tokens(rt).values()), {"mit"}) - def test_position_core_runs_at_400_with_a4_and_pv_tokens(self) -> None: + def test_position_core_runs_at_400_with_a4_tokens_and_mit_wrists(self) -> None: rt = Axol._wrap(_hardware(AxolConfig(controller="position"))) lines = rt._config_text().splitlines() self.assertIn("loop_hz 400.0", lines) @@ -132,7 +142,7 @@ def test_position_core_runs_at_400_with_a4_and_pv_tokens(self) -> None: for j in _MYACTUATOR: self.assertEqual(tokens[j.value], "a4") for j in _DAMIAO: - self.assertEqual(tokens[j.value], "pv") + self.assertEqual(tokens[j.value], "mit") def test_an_explicit_loop_rate_still_wins(self) -> None: rt = Axol._wrap(_hardware(AxolConfig(controller="position")), loop_hz=240.0) diff --git a/web/app/src/components/diagnostics/tuning-workbench.tsx b/web/app/src/components/diagnostics/tuning-workbench.tsx index 338744f1..fb9dd9e0 100644 --- a/web/app/src/components/diagnostics/tuning-workbench.tsx +++ b/web/app/src/components/diagnostics/tuning-workbench.tsx @@ -463,11 +463,11 @@ const TABS: WbTab[] = [ hint: "impedance (240 Hz) is the production MIT frame: host gravity, " + "friction, inertia and damping feed-forward around the firmware PD, " + - "compliant. position (400 Hz) hands every joint to its motor's own " + - "position loop — MyActuator 0xA4, Damiao position-velocity, the gains " + - "on the Firmware-loop tab — streamed at 400 Hz, where the loop's " + - "target staircase is gone: stiff, no host feed-forward, NaN torque " + - "on the MyActuator joints (contact watchdog blind there). Same " + + "compliant. position (400 Hz) hands the five MyActuator joints to " + + "their motor's own 0xA4 position loop — the gains on the Firmware-loop " + + "tab — streamed at 400 Hz, where the loop's target staircase is gone: " + + "stiff, no host feed-forward, NaN torque on those joints (contact " + + "watchdog blind there); the Damiao wrists stay on impedance. Same " + "motion, same scoring, so the two controllers compare directly.", }, { key: "stiffness", label: "stiffness s", type: "number", placeholder: "1" }, @@ -481,10 +481,12 @@ const TABS: WbTab[] = [ "their 0xA4 firmware loop for this run only (--a4 side.joint), the rest " + "staying on impedance — no compliance, no host feed-forward and NaN " + "torque telemetry on that joint. Everything else about the replay is " + - "unchanged, so runs compare directly. The Damiao wrists' firmware loop " + - "(position-velocity) comes with the position controller above, which " + - "puts every joint on its firmware loop at 400 Hz. A joint already " + - "configured wire_mode a4 is pinned.", + "unchanged, so runs compare directly. The position controller above " + + "puts all five at once, at 400 Hz. The Damiao wrists have a " + + "position-velocity loop too (wire_mode pv in the robot config), kept " + + "off by default: it stick-slips at creep speed and its stiff hold " + + "pumps the extended arm's 4 Hz sway. A joint already configured " + + "wire_mode a4 is pinned.", }, { key: "ik", From 01e945813f2eb3d9a08fcc68d774d03423b39091 Mon Sep 17 00:00:00 2001 From: Shawn Patel Date: Tue, 22 Sep 2026 20:32:19 +0000 Subject: [PATCH 43/80] core: a thinned joint's latest sample stays fresh across its off-ticks Every commanded tick of a thinned MIT joint followed an off-tick that had cleared feedback_fresh, so stiction, Stribeck and host damping could never act on the wrists at 400 Hz. Co-Authored-By: Claude Fable 5.1 --- rust/axol-rt/src/serve.rs | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/rust/axol-rt/src/serve.rs b/rust/axol-rt/src/serve.rs index efa3ce9d..8191d7e3 100644 --- a/rust/axol-rt/src/serve.rs +++ b/rust/axol-rt/src/serve.rs @@ -3291,9 +3291,12 @@ fn bus_loop( } if !attempted[idx] { // Not this motor's tick on the thinned schedule: no - // reply was owed, so none is missing — but the sample - // the damping chain would act on is a tick old. - feedback_fresh[motor.slot] = false; + // reply was owed, so none is missing, and the latest + // sample stays "fresh" — it is the newest the schedule + // can produce. Clearing it here would leave the + // feedforwards that need a sample (stiction, Stribeck, + // host damping) permanently off on a thinned MIT joint, + // since every commanded tick follows an off-tick. continue; } let complete = reply_complete(&expected, &seen, idx); From 0ed0d002406c85215cc7a1b642b760348331077b Mon Sep 17 00:00:00 2001 From: Shawn Patel Date: Tue, 22 Sep 2026 21:08:25 +0000 Subject: [PATCH 44/80] tests: controller_option config tests stub find_binary so CI needs no axol-rt build Co-Authored-By: Claude Opus 5.5 (1M context) --- tests/test_controller_option.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tests/test_controller_option.py b/tests/test_controller_option.py index aeb072d3..428512b0 100644 --- a/tests/test_controller_option.py +++ b/tests/test_controller_option.py @@ -120,6 +120,13 @@ def _hardware(config: AxolConfig) -> AxolHardware: class RealtimeConfigTest(unittest.TestCase): + def setUp(self) -> None: + # Only the config text is exercised; no core is spawned, so CI needs + # no built axol-rt binary. + patcher = patch("almond_axol.rt.link.find_binary", return_value="/fake/axol-rt") + patcher.start() + self.addCleanup(patcher.stop) + def _joint_tokens(self, rt: Axol) -> dict[str, str]: out = {} for line in rt._config_text().splitlines(): From 54c61228d764c2cce7e968044d60a73fb4d57fa5 Mon Sep 17 00:00:00 2001 From: Shawn Patel Date: Tue, 22 Sep 2026 21:37:06 +0000 Subject: [PATCH 45/80] Position controller: every joint on its firmware loop, Damiao wrists on pv Reverts the wrist half of 15e2b6a: under controller=position the Damiao wrists now take their position-velocity loop (wire_mode pv) alongside the MyActuator joints on 0xA4, instead of staying on the impedance frame. On the 0ed0d00 build the impedance wrists buzzed at ~85 Hz on slow_osc (right wrist_3 motor speed 2.2 rad/s RMS vs 0.04 on e5), taking the arm with them. The pv costs 15e2b6a measured still apply and are to be re-checked with the wrist profiler ramp now in config: stick-slip at creep speed and a stiff hold that pumped the extended arm's 4.3 Hz sway. Co-Authored-By: Claude Opus 5.5 (1M context) --- almond_axol/cli/tune/motion.py | 12 +++--- almond_axol/robot/config.py | 40 ++++++++----------- docs/cli/teleop.mdx | 2 +- docs/cli/tune-motion.mdx | 2 +- docs/guides/diagnostics-dashboard.mdx | 2 +- docs/snippets/config/robot.mdx | 2 +- tests/test_controller_option.py | 18 ++------- .../diagnostics/tuning-workbench.tsx | 20 +++++----- 8 files changed, 40 insertions(+), 58 deletions(-) diff --git a/almond_axol/cli/tune/motion.py b/almond_axol/cli/tune/motion.py index 0a214e6b..4d64cdd6 100644 --- a/almond_axol/cli/tune/motion.py +++ b/almond_axol/cli/tune/motion.py @@ -335,11 +335,11 @@ def add_parser(subparsers: argparse._SubParsersAction) -> None: # type: ignore[ default=None, help="Which control law the core runs the arms on for this run. " "'impedance' (the config default) is the production MIT frame at 240 Hz " - "with the host feedforward; 'position' puts the MyActuator joints on " - "their motor's own 0xA4 position loop (the firmware.* gains) streamed at " - "400 Hz — stiff, no host feedforward, NaN torque on those joints — with " - "the Damiao wrists staying on impedance (a wrist's wire_mode pv opts it " - "in). --a4 still adds single joints inside the impedance controller.", + "with the host feedforward; 'position' puts every joint on its motor's " + "own position loop (MyActuator 0xA4, Damiao position-velocity; the " + "firmware.* gains) streamed at 400 Hz — stiff, no host feedforward, " + "NaN torque on the MyActuator joints. --a4 still adds single joints " + "inside the impedance controller.", ) p.add_argument( "--arms", @@ -592,7 +592,7 @@ async def _run(args: argparse.Namespace) -> None: f" controller: {config.controller} " f"({(args.loop_hz or config.loop_hz):.0f} Hz core loop" + ( - ", MyActuator joints on their firmware position loop)" + ", every joint on its firmware position loop)" if config.controller == "position" else ")" ) diff --git a/almond_axol/robot/config.py b/almond_axol/robot/config.py index d2c23d79..a52d2b54 100644 --- a/almond_axol/robot/config.py +++ b/almond_axol/robot/config.py @@ -935,16 +935,12 @@ def _apply_stiffness(arm: ArmConfig, s: float | Sequence[float]) -> ArmConfig: #: #: ``"impedance"`` is the production MIT frame: host gravity / friction / #: inertia feedforward and host damping around the firmware PD, compliant, -#: at 240 Hz. ``"position"`` hands the five MyActuator joints to their -#: motor's own position loop (0xA4, gains from each joint's ``firmware`` -#: block) streamed at 400 Hz, where the loop's target staircase (audible at -#: 200 Hz) is gone. They are stiff: no compliance, no host feedforward, the -#: contact watchdog blind on them. The Damiao wrists stay on the impedance -#: frame: their position-velocity loop (``wire_mode`` ``pv``) stick-slips at -#: creep speed (3x the impedance frame's 3-15 Hz error at 4 deg/s) and its -#: stiff hold pumps the extended arm's 4.3 Hz sway (2026-09-22, both -#: shoulders at 0.27° p2p in the wrist-sweep pose), so it stays a per-joint -#: opt-in for experiments. The bus cannot carry every motor every tick at +#: at 240 Hz. ``"position"`` hands every joint to its motor's own position +#: loop — 0xA4 on the MyActuator joints, position-velocity on the Damiao +#: wrists, gains from each joint's ``firmware`` block — streamed at 400 Hz, +#: where the loop's target staircase (audible at 200 Hz) is gone. It is +#: stiff: no compliance, no host feedforward, the contact watchdog blind on +#: the MyActuator joints. The bus cannot carry every motor every tick at #: 400 Hz, so the core thins its schedule (wrists commanded on alternate #: ticks, one a4 fine-position read per tick round-robin, gripper in that #: rotation); the MyActuator commands themselves go out every tick. @@ -964,13 +960,12 @@ def position_wire_mode(joint: Joint) -> str: def _on_position_loops(arm: ArmConfig) -> ArmConfig: - """The MyActuator joints on 0xA4; the Damiao wrists as configured.""" + """Every arm joint on its vendor's firmware position loop.""" return replace( arm, **{ - j.value: replace(getattr(arm, j.value), wire_mode="a4") + j.value: replace(getattr(arm, j.value), wire_mode=position_wire_mode(j)) for j in ARM_JOINTS - if _JOINT_CONFIG[j].motor_id <= 5 }, ) @@ -1023,16 +1018,15 @@ class AxolConfig: controller: Which control law the realtime core runs the arms on — see :data:`CONTROLLERS`. ``"impedance"`` (default) is the production MIT frame at 240 Hz. - ``"position"`` puts the MyActuator joints on their - firmware position loop (``wire_mode`` ``a4``, the - ``firmware`` gains) at 400 Hz; the Damiao wrists - keep their configured ``wire_mode`` (``mit`` unless - set to ``pv``). Like stiffness it is baked into the - per-joint ``wire_mode`` fields by :meth:`resolved`; - a per-joint ``wire_mode`` set explicitly under - ``"impedance"`` is kept, so one joint can still be - tried on its firmware loop inside the impedance - controller (``tune.motion --a4``). + ``"position"`` puts every joint on its firmware + position loop (``wire_mode`` ``a4`` / ``pv``, the + ``firmware`` gains) at 400 Hz. Like stiffness it is + baked into the per-joint ``wire_mode`` fields by + :meth:`resolved`; a per-joint ``wire_mode`` set + explicitly under ``"impedance"`` is kept, so one + joint can still be tried on its firmware loop + inside the impedance controller (``tune.motion + --a4``). """ left: ArmConfig = field( diff --git a/docs/cli/teleop.mdx b/docs/cli/teleop.mdx index 058867a2..b750fd5c 100644 --- a/docs/cli/teleop.mdx +++ b/docs/cli/teleop.mdx @@ -5,7 +5,7 @@ description: "Launch a VR teleoperation session on the robot or simulator." Launches a VR teleoperation session. When started, the hostname (`.local`) and local IP address are printed — enter either of these in the VR app at [axol.almond.bot](https://axol.almond.bot) to connect. -Runs on the **real robot by default**; pass `--sim` for the browser visualizer. Hardware control always uses the required `axol-rt` realtime core, which owns CAN — at 240 Hz on the default impedance controller, or at 400 Hz with `--axol.controller position`, which runs the MyActuator joints on their motor's own firmware position loop (see `controller` in the [robot config](/cli/configuration)) — while Python handles VR, IK, and the slow model. The full robot config is exposed via draccus, so every gain, stiffness, and gripper limit is reachable from the CLI or a config file — see [Command configuration](/cli/configuration) for the syntax. The table below covers the commonly used flags; every field of the nested `axol`, `teleop`, `kinematics`, `vr_server`, and `cart` configs is listed with its default in [Teleoperation Config Parameters](/operations/teleop-config). +Runs on the **real robot by default**; pass `--sim` for the browser visualizer. Hardware control always uses the required `axol-rt` realtime core, which owns CAN — at 240 Hz on the default impedance controller, or at 400 Hz with `--axol.controller position`, which runs every joint on its motor's own firmware position loop (see `controller` in the [robot config](/cli/configuration)) — while Python handles VR, IK, and the slow model. The full robot config is exposed via draccus, so every gain, stiffness, and gripper limit is reachable from the CLI or a config file — see [Command configuration](/cli/configuration) for the syntax. The table below covers the commonly used flags; every field of the nested `axol`, `teleop`, `kinematics`, `vr_server`, and `cart` configs is listed with its default in [Teleoperation Config Parameters](/operations/teleop-config). For a guided, step-by-step walkthrough including the VR controller layout, see the [Teleoperation guide](/operations/teleop). diff --git a/docs/cli/tune-motion.mdx b/docs/cli/tune-motion.mdx index fc6db677..a0067a4d 100644 --- a/docs/cli/tune-motion.mdx +++ b/docs/cli/tune-motion.mdx @@ -24,7 +24,7 @@ The arm moves to the motion's start and back to rest on collision-aware planned | `--a4 SIDE.JOINT` | Drive one MyActuator joint with the firmware position loop (0xA4) instead of the MIT frame for this run, e.g. `right.shoulder_1`; repeatable. No compliance, no host feed-forward, NaN torque on that joint — see `wire_mode` in the [config reference](/cli/configuration) | | `--loop-hz HZ` | Realtime-core tick rate override for A/B runs (default follows `--controller`: 240 impedance, 400 position) — e.g. `--controller position --loop-hz 240` to separate the rate from the controller | | `--record PREFIX` | Flight recorder, as teleop's `--teleop.record`: the replay's measured joints to `PREFIX_meas.npz` and the core's per-tick trace (target, command, measured position, motor speed, feed-forward terms) to `PREFIX_rt.npz` | -| `--controller impedance\|position` | Which control law the core runs the arms on for this run: `impedance` (the config default) is the production MIT frame at 240 Hz with the host feed-forward; `position` puts the MyActuator joints on their motor's own 0xA4 position loop (the `firmware.*` gains) streamed at 400 Hz, the Damiao wrists staying on impedance unless a wrist's `wire_mode` is `pv`. Stiff, no host feed-forward, NaN torque on those joints. Same motion and scoring, so the two controllers compare directly; `--a4` still adds single joints inside the impedance controller. See `controller` in the [config reference](/cli/configuration) | +| `--controller impedance\|position` | Which control law the core runs the arms on for this run: `impedance` (the config default) is the production MIT frame at 240 Hz with the host feed-forward; `position` puts every joint on its motor's own position loop — MyActuator 0xA4, Damiao position-velocity, the `firmware.*` gains — streamed at 400 Hz. Stiff, no host feed-forward, NaN torque on the MyActuator joints. Same motion and scoring, so the two controllers compare directly; `--a4` still adds single joints inside the impedance controller. See `controller` in the [config reference](/cli/configuration) | | `--arms both\|left\|right` | Which arm(s) to bring up and drive (default: `both`). The other arm's bus is left untouched, so an unpowered arm or a single-arm bench does not block the run | | `--no-gripper` | Run on the gripperless SKU | diff --git a/docs/guides/diagnostics-dashboard.mdx b/docs/guides/diagnostics-dashboard.mdx index 4408f647..d0cadc3c 100644 --- a/docs/guides/diagnostics-dashboard.mdx +++ b/docs/guides/diagnostics-dashboard.mdx @@ -61,7 +61,7 @@ Gain fields (kp, kd, kd_host, kd_host_hz, kd_host_q) show the selected joint's * - **Factory** — [`tune.factory`](https://docs.almond.bot/cli/tune-factory) runs friction + gravity for **all 14 joints** in one go (both arms, distal→proximal), saving every fit to this robot's calibration and uploading the document to the cloud keyed by the hub adapter serial when Supabase credentials are configured. This is the one-button factory calibration; expect a long run. To compare two runs, tick their checkboxes in the run list (same run kind — sine vs sine, motion vs motion). The view switches to an overlay: commanded plus both runs' outputs per joint (A yellow, B blue), both error traces in the lane, a verdict line naming the better run by its headline score, and an A-vs-B score table with the better value of each pair in green. Untick (or Exit compare) to go back to single-run view. -- **Recorded motion** — [`tune.motion`](https://docs.almond.bot/cli/tune-motion) replays a committed reference motion through the production Rust-core control path and scores tracking per joint. The gain table shows every joint's current config value (defaults + this robot's calibration) for each gain field; edit a cell to override it **for this run only, on both arms** — edited cells highlight, everything else runs with config. Clearing a cell drops the override and snaps it back to the config value (**reset overrides** clears them all). The **controller** select picks the control law for the whole run: **impedance** (the production MIT frame with the host feed-forward, 240 Hz) or **position** (`--controller position`: the five MyActuator joints on their motor's own 0xA4 position loop — the gains on the Firmware-loop tab — streamed at 400 Hz, the Damiao wrists staying on impedance). The **controller per joint** grid below it picks, inside the impedance controller, per arm and MyActuator joint, whether that joint runs on **impedance** or on its **firmware** loop (`--a4 side.joint`) for this run — e.g. shoulder_1 on the firmware loop with everything else on impedance, scored against the identical motion; a joint the config already pins to `wire_mode a4` shows as firmware and cannot be switched back. Tick **run as IK** to drive the run through the IK solver instead of raw joint replay: the motion's Cartesian end-effector path (FK of the reference, with elbow hints) is re-solved to joints like teleop's pose→joints loop and the arms execute the solver's output — still scored against the clean reference, so IK reconstruction error and tracking show up together in the same charts. The tab also carries the filter-stack test on real hardware: pick a **noise** source (**network** = seeded jitter/outliers/stalls injected before the pose low-pass, **IK** = solver churn/jumps injected after it, or **combined** — each at its real entry point in the pipeline, deterministic per seed) and toggle the **filter stack** (pose low-pass → EMA → trapezoid). The run is scored against the clean reference either way, and the charts overlay **commanded (clean), sent (what was actually streamed), and actual** per joint — run once with the filters off and once on to see, on the physical arm, what the stack removes and what it costs in lag. +- **Recorded motion** — [`tune.motion`](https://docs.almond.bot/cli/tune-motion) replays a committed reference motion through the production Rust-core control path and scores tracking per joint. The gain table shows every joint's current config value (defaults + this robot's calibration) for each gain field; edit a cell to override it **for this run only, on both arms** — edited cells highlight, everything else runs with config. Clearing a cell drops the override and snaps it back to the config value (**reset overrides** clears them all). The **controller** select picks the control law for the whole run: **impedance** (the production MIT frame with the host feed-forward, 240 Hz) or **position** (`--controller position`: every joint on its motor's own firmware position loop — MyActuator 0xA4, Damiao position-velocity, the gains on the Firmware-loop tab — streamed at 400 Hz). The **controller per joint** grid below it picks, inside the impedance controller, per arm and MyActuator joint, whether that joint runs on **impedance** or on its **firmware** loop (`--a4 side.joint`) for this run — e.g. shoulder_1 on the firmware loop with everything else on impedance, scored against the identical motion; a joint the config already pins to `wire_mode a4` shows as firmware and cannot be switched back. Tick **run as IK** to drive the run through the IK solver instead of raw joint replay: the motion's Cartesian end-effector path (FK of the reference, with elbow hints) is re-solved to joints like teleop's pose→joints loop and the arms execute the solver's output — still scored against the clean reference, so IK reconstruction error and tracking show up together in the same charts. The tab also carries the filter-stack test on real hardware: pick a **noise** source (**network** = seeded jitter/outliers/stalls injected before the pose low-pass, **IK** = solver churn/jumps injected after it, or **combined** — each at its real entry point in the pipeline, deterministic per seed) and toggle the **filter stack** (pose low-pass → EMA → trapezoid). The run is scored against the clean reference either way, and the charts overlay **commanded (clean), sent (what was actually streamed), and actual** per joint — run once with the filters off and once on to see, on the physical arm, what the stack removes and what it costs in lag. - **Build motion** — [`motion.build`](https://docs.almond.bot/cli/motion-build) turns a recorded session — teleoperated (`axol teleop --teleop.record`) or hand-guided in gravity comp (`axol gravity-comp --record`, also settable per run on the gravity-comp operation panel) — into a reference motion, which then shows up in the Recorded motion picker. Every build saves a **before/after run**: the raw recorded command stream vs the built motion per joint (with a built−recorded lane), so the smoothing pass and the collision projection are visually checkable — with the same zoom/fullscreen as every other chart — before the motion is replayed. - **IK** — [`diag.offline kinematics`](https://docs.almond.bot/cli/diag-offline) analyzes a teleop recording offline (no hardware, defaults to the newest recording): per-axis charts of the **EE pose set vs actual** (the world target given to the solver vs FK of what it solved) with an error lane in mm, the per-tick **solve time** trace (spikes here surface as teleop lurches), and a per-joint churn/jitter scorecard that catches a restless null space. diff --git a/docs/snippets/config/robot.mdx b/docs/snippets/config/robot.mdx index c4c4b54b..d1acfb52 100644 --- a/docs/snippets/config/robot.mdx +++ b/docs/snippets/config/robot.mdx @@ -6,7 +6,7 @@ export const F = ({ children }) => {child | --{prefix}right_stiffness | `1.0` | Same, for the right arm. | | --{prefix}has_gripper | `true` | Set `false` on the gripperless model. The gripper motors are never enabled and datasets record 7 channels per arm instead of 8. | | --{prefix}max_step_rad | `0.5` | Largest joint move (rad) allowed between two consecutive commands. Bigger moves are dropped with a warning. `inf` disables. | -| --{prefix}controller | `impedance` | Which control law the realtime core runs the arms on. `impedance` is the production MIT frame at **240 Hz**: host gravity / friction / inertia feed-forward and host damping around the firmware PD, compliant. `position` hands the five **MyActuator** joints to their motor's own 0xA4 position loop at **400 Hz** (the `firmware.*` gains below, written to ROM at enable), where the position loop's target staircase (audible at 200 Hz) is gone; those joints are stiff — no compliance, no host feed-forward, and the contact watchdog is blind on them (their torque telemetry reads NaN). The Damiao wrists stay on the impedance frame: their position-velocity loop (`wire_mode` `pv`, opt-in per wrist) stick-slips at creep speed (3× the impedance frame's 3-15 Hz error at 4 deg/s) and its stiff hold pumps the extended arm's 4.3 Hz sway (both shoulders at 0.27° p2p in the wrist-sweep pose, 2026-09-22). At 400 Hz the bus cannot carry every motor every tick, so the core thins its schedule: every MyActuator command still goes out every tick, the two wrists are commanded on alternate ticks (200 Hz each), and one 0.01° a4 position read per tick rotates through the a4 joints and the gripper (~67 Hz each; between reads a joint's position is carried on the speed its command echo reports every tick). Measured on `slow_osc`: the MyActuator joints' 3-15 Hz tracking error halves or better against impedance (shoulder_1 0.23 vs 0.50 mrad, elbow 0.10 vs 0.74), at ~30 ms more lag (the firmware loop's own) and with the motor's 20-cycles-per-rev torque ripple audible as a 65-90 Hz tone above ~50 deg/s (3-6 deg/s of speed ripple, where impedance shows ~2 at that line plus 4-5 at its 32-45 Hz structural mode; the speed-loop gain does not move it). The choice is baked into each joint's `wire_mode` when the robot is constructed; under `impedance` a per-joint `wire_mode` set explicitly is kept. Recorded-motion replays take `--controller` ([`tune.motion`](/cli/tune-motion)); teleop takes `--axol.controller position`. | +| --{prefix}controller | `impedance` | Which control law the realtime core runs the arms on. `impedance` is the production MIT frame at **240 Hz**: host gravity / friction / inertia feed-forward and host damping around the firmware PD, compliant. `position` hands every joint to its motor's own position loop at **400 Hz** — 0xA4 on the MyActuator joints, position-velocity on the Damiao wrists, the `firmware.*` gains below (written to ROM at enable) — where the position loop's target staircase (audible at 200 Hz) is gone. It is stiff: no compliance, no host feed-forward, and the contact watchdog is blind on the MyActuator joints (their torque telemetry reads NaN). At 400 Hz the bus cannot carry every motor every tick, so the core thins its schedule: every MyActuator command still goes out every tick, the two wrists are commanded on alternate ticks (200 Hz each), and one 0.01° a4 position read per tick rotates through the a4 joints and the gripper (~67 Hz each; between reads a joint's position is carried on the speed its command echo reports every tick). The choice is baked into each joint's `wire_mode` when the robot is constructed; under `impedance` a per-joint `wire_mode` set explicitly is kept. Recorded-motion replays take `--controller` ([`tune.motion`](/cli/tune-motion)); teleop takes `--axol.controller position`. | | --{prefix}left.gripper.torque_limit | `0.5` | Grip force — peak gripper torque (Nm). | | --{prefix}left.gripper.max_speed | `10.0` | Maximum gripper speed (rad/s). | | --{prefix}left.elbow.kp | per joint | Position stiffness of one joint, in `[0, 500]`. This is the stiffness-`1` value. | diff --git a/tests/test_controller_option.py b/tests/test_controller_option.py index 428512b0..a823f179 100644 --- a/tests/test_controller_option.py +++ b/tests/test_controller_option.py @@ -41,7 +41,7 @@ def test_impedance_is_the_default_and_leaves_wire_modes_alone(self) -> None: for j in ARM_JOINTS: self.assertEqual(getattr(arm, j.value).wire_mode, "mit") - def test_position_puts_the_myactuator_joints_on_a4_at_400_hz(self) -> None: + def test_position_puts_every_joint_on_its_vendors_loop_at_400_hz(self) -> None: cfg = AxolConfig(controller="position") self.assertEqual(cfg.loop_hz, 400.0) resolved = cfg.resolved() @@ -49,21 +49,11 @@ def test_position_puts_the_myactuator_joints_on_a4_at_400_hz(self) -> None: for arm in (resolved.left, resolved.right): for j in _MYACTUATOR: self.assertEqual(getattr(arm, j.value).wire_mode, "a4", j) - # The Damiao wrists keep the impedance frame (their pv loop - # stick-slips at creep and pumps the arm's 4 Hz sway). for j in _DAMIAO: - self.assertEqual(getattr(arm, j.value).wire_mode, "mit", j) + self.assertEqual(getattr(arm, j.value).wire_mode, "pv", j) # Idempotent, like the stiffness blend. self.assertEqual(resolved.resolved(), resolved) - def test_a_wrist_opted_into_pv_survives_the_position_controller(self) -> None: - cfg = AxolConfig(controller="position") - cfg.right.wrist_2.wire_mode = "pv" - resolved = cfg.resolved() - self.assertEqual(resolved.right.wrist_2.wire_mode, "pv") - self.assertEqual(resolved.right.wrist_3.wire_mode, "mit") - self.assertEqual(resolved.right.elbow.wire_mode, "a4") - def test_position_wire_mode_follows_the_motor_vendor(self) -> None: for j in _MYACTUATOR: self.assertEqual(position_wire_mode(j), "a4") @@ -141,7 +131,7 @@ def test_impedance_core_runs_at_240_on_mit(self) -> None: self.assertIn("loop_hz 240.0", lines) self.assertEqual(set(self._joint_tokens(rt).values()), {"mit"}) - def test_position_core_runs_at_400_with_a4_tokens_and_mit_wrists(self) -> None: + def test_position_core_runs_at_400_with_a4_and_pv_tokens(self) -> None: rt = Axol._wrap(_hardware(AxolConfig(controller="position"))) lines = rt._config_text().splitlines() self.assertIn("loop_hz 400.0", lines) @@ -149,7 +139,7 @@ def test_position_core_runs_at_400_with_a4_tokens_and_mit_wrists(self) -> None: for j in _MYACTUATOR: self.assertEqual(tokens[j.value], "a4") for j in _DAMIAO: - self.assertEqual(tokens[j.value], "mit") + self.assertEqual(tokens[j.value], "pv") def test_an_explicit_loop_rate_still_wins(self) -> None: rt = Axol._wrap(_hardware(AxolConfig(controller="position")), loop_hz=240.0) diff --git a/web/app/src/components/diagnostics/tuning-workbench.tsx b/web/app/src/components/diagnostics/tuning-workbench.tsx index fb9dd9e0..338744f1 100644 --- a/web/app/src/components/diagnostics/tuning-workbench.tsx +++ b/web/app/src/components/diagnostics/tuning-workbench.tsx @@ -463,11 +463,11 @@ const TABS: WbTab[] = [ hint: "impedance (240 Hz) is the production MIT frame: host gravity, " + "friction, inertia and damping feed-forward around the firmware PD, " + - "compliant. position (400 Hz) hands the five MyActuator joints to " + - "their motor's own 0xA4 position loop — the gains on the Firmware-loop " + - "tab — streamed at 400 Hz, where the loop's target staircase is gone: " + - "stiff, no host feed-forward, NaN torque on those joints (contact " + - "watchdog blind there); the Damiao wrists stay on impedance. Same " + + "compliant. position (400 Hz) hands every joint to its motor's own " + + "position loop — MyActuator 0xA4, Damiao position-velocity, the gains " + + "on the Firmware-loop tab — streamed at 400 Hz, where the loop's " + + "target staircase is gone: stiff, no host feed-forward, NaN torque " + + "on the MyActuator joints (contact watchdog blind there). Same " + "motion, same scoring, so the two controllers compare directly.", }, { key: "stiffness", label: "stiffness s", type: "number", placeholder: "1" }, @@ -481,12 +481,10 @@ const TABS: WbTab[] = [ "their 0xA4 firmware loop for this run only (--a4 side.joint), the rest " + "staying on impedance — no compliance, no host feed-forward and NaN " + "torque telemetry on that joint. Everything else about the replay is " + - "unchanged, so runs compare directly. The position controller above " + - "puts all five at once, at 400 Hz. The Damiao wrists have a " + - "position-velocity loop too (wire_mode pv in the robot config), kept " + - "off by default: it stick-slips at creep speed and its stiff hold " + - "pumps the extended arm's 4 Hz sway. A joint already configured " + - "wire_mode a4 is pinned.", + "unchanged, so runs compare directly. The Damiao wrists' firmware loop " + + "(position-velocity) comes with the position controller above, which " + + "puts every joint on its firmware loop at 400 Hz. A joint already " + + "configured wire_mode a4 is pinned.", }, { key: "ik", From b1e431bfe4e9ee2d6d5f65d995815ab0e42660c0 Mon Sep 17 00:00:00 2001 From: Shawn Patel Date: Tue, 22 Sep 2026 22:01:39 +0000 Subject: [PATCH 46/80] CAN purge: USB-reset the arm hub, whose firmware replays frames a flap leaves behind The hub firmware keeps the frames it had already accepted from the host (up to GS_MAX_TX_URBS, 10 per channel) through gs_can_close()'s mode reset and transmits them on the next open. On almond-axol-zed-box (2026-09-22) the kernel logged ten "Unexpected unused echo id" per channel when axol serve opened the buses 40 min after a stall, and the right arm drove to a stalled run's last pose during can.setup's recovery cycles; the left arm, which that run never commanded, stayed put. The purge's link flap only clears the kernel's queue, and purge_stale_tx only looks there. can.setup now writes /etc/almond-axol/can/reset_adapter.sh: usbreset the device behind the arm channels (sysfs deauthorize/reauthorize if usbreset is missing or fails), wait for udev to rename the new netdevs, then exec the bring-up script with the global lock still held. It runs from: - the core's purge_tx_queue on an arm-hub channel, falling back to the flap when it cannot run (a robot not yet re-provisioned); - the bring-up backstop's flap when arm channels are poisoned; - can.setup's pair-recovery cycles of a silent arm hub. axol provision grants it alongside the bring-up script. The wheel/chest adapters and the Mantis hub keep the plain flap. Tests: the purge and backstop tests pin both script paths to temp files so a host that has them in /etc (a robot) never reaches real sudo. Co-Authored-By: Claude Opus 5.5 (1M context) --- almond_axol/cli/can/setup.py | 126 +++++++++++++++++++++++++++++++-- almond_axol/constants.py | 7 ++ almond_axol/utils/can_purge.py | 14 ++-- docs/cli/can-setup.mdx | 4 +- docs/cli/provision.mdx | 2 +- rust/axol-rt/src/safety.rs | 61 ++++++++++++++-- tests/test_can_purge.py | 62 +++++++++++++++- tests/test_can_setup.py | 80 +++++++++++++++++++++ 8 files changed, 338 insertions(+), 18 deletions(-) diff --git a/almond_axol/cli/can/setup.py b/almond_axol/cli/can/setup.py index ec47ccae..7dd6a386 100644 --- a/almond_axol/cli/can/setup.py +++ b/almond_axol/cli/can/setup.py @@ -81,6 +81,7 @@ CAN_LEFT, CAN_MANTIS_LEFT, CAN_MANTIS_RIGHT, + CAN_RESET_SCRIPT, CAN_RIGHT, Joint, ) @@ -1400,10 +1401,10 @@ def _identify_dual_adapter(serial: str, *, reset: bool = False) -> DualHubIdenti # a healthy first pass, but if either half is down recover the adapter # pair together before probing. Pair-wide ordering matters for this # dual-channel firmware: both channels go down before either comes up. - bring_up_interfaces( - ifaces, - force_cycle=reset or not all(iface_up(iface) for iface in ifaces), - ) + if reset or not all(iface_up(iface) for iface in ifaces): + _recover_hub_pair(ifaces) + else: + bring_up_interfaces(ifaces, force_cycle=False) except RuntimeError: return "silent" @@ -1429,7 +1430,7 @@ def _identify_dual_adapter(serial: str, *, reset: bool = False) -> DualHubIdenti " No identity response; recovering both CAN channels and retrying..." ) try: - bring_up_interfaces(ifaces, force_cycle=True) + _recover_hub_pair(ifaces) except RuntimeError: return "silent" return "silent" @@ -1940,6 +1941,109 @@ def _write_cron_script(profile: _Profile = _AXOL_PROFILE) -> None: print(" Done.") +def _reset_script_text() -> str: + """The arm hub's USB reset: clears what a link flap cannot, then brings up. + + ``gs_can_close()`` kills the host's in-flight URBs and sends the adapter a + mode reset, but the hub firmware keeps the frames it had already accepted + (up to ``GS_MAX_TX_URBS``, 10 per channel) and transmits them on the next + open: the kernel logs one "Unexpected unused echo id" per replayed frame. + Behind a stalled bus those are the session's last position commands, so a + flap only defers the jump to whatever brings the bus back. A USB port + reset re-enumerates the hub; the bring-up script then configures the + fresh interfaces. Only the arm hub is reset — the single-channel wheel + and chest adapters keep the plain flap the bring-up script gives them. + """ + left, right = _AXOL_PROFILE.left, _AXOL_PROFILE.right + return ( + f"#!/bin/bash\n" + f"# USB-reset the Almond Axol arm hub, then bring the CAN interfaces up.\n" + f"#\n" + f"# The hub firmware keeps frames it already accepted through a link\n" + f"# down/up and transmits them on the next open, so the purge after a\n" + f"# stalled bus resets the device instead of flapping its channels.\n" + f"# Written by `axol can.setup`; run by the realtime core's purge and by\n" + f"# can.setup's recovery (granted by `axol provision`).\n" + f"set -euo pipefail\n\n" + f'if [ "${{{_GLOBAL_LOCK_ENV}:-}}" != "1" ]; then\n' + f' exec 8<"{_GLOBAL_LOCK_FILE}"\n' + f" flock 8\n" + f"fi\n\n" + f"HUB=\n" + f"for IFACE in {left} {right}; do\n" + f' if [ -e "/sys/class/net/${{IFACE}}/device" ]; then\n' + f' HUB=$(readlink -f "/sys/class/net/${{IFACE}}/device/..")\n' + f" break\n" + f" fi\n" + f"done\n\n" + f'if [ -n "${{HUB}}" ] && [ -f "${{HUB}}/busnum" ]; then\n' + f' BUS=$(cat "${{HUB}}/busnum")\n' + f' DEV=$(cat "${{HUB}}/devnum")\n' + f" if command -v usbreset >/dev/null 2>&1 && " + f'usbreset "$(printf "%03d/%03d" "${{BUS}}" "${{DEV}}")"; then\n' + f' echo "arm hub ${{HUB##*/}}: USB reset"\n' + f" else\n" + f" # No usbreset (or it failed): deauthorize and reauthorize, which\n" + f" # unbinds gs_usb and re-enumerates the hub the same way.\n" + f' echo 0 > "${{HUB}}/authorized"\n' + f" sleep 0.5\n" + f' echo 1 > "${{HUB}}/authorized"\n' + f' echo "arm hub ${{HUB##*/}}: reauthorized"\n' + f" fi\n" + f" # gs_usb rebinds inside the reset; udev renames the new netdevs.\n" + f" for _ in $(seq 1 50); do\n" + f" if ip link show {left} >/dev/null 2>&1 " + f"&& ip link show {right} >/dev/null 2>&1; then\n" + f" break\n" + f" fi\n" + f" sleep 0.1\n" + f" done\n" + f"else\n" + f' echo "{left}/{right} not present — no hub to reset"\n' + f"fi\n\n" + f"# fd 8 (the global lock) survives the exec; the flag stops the bring-up\n" + f"# script from reopening it and deadlocking on itself.\n" + f'exec env {_GLOBAL_LOCK_ENV}=1 bash "{_AXOL_PROFILE.cron_script}"\n' + ) + + +def _write_reset_script() -> None: + """Install :func:`_reset_script_text` at :data:`CAN_RESET_SCRIPT`.""" + print(f"Writing CAN adapter reset script to {CAN_RESET_SCRIPT}...") + _ensure_root_lock_file(_GLOBAL_LOCK_FILE) + _install_privileged_script(CAN_RESET_SCRIPT, _reset_script_text()) + print(" Done.") + + +def _root_script_command(script: Path) -> list[str]: + """``bash script``, flagged when this thread already holds the global lock. + + The generated scripts take the global lock themselves; reopening it while + this thread owns it would deadlock. The unflagged form is the one + ``axol provision`` grants for non-interactive callers. + """ + command = ["bash", str(script)] + if int(getattr(_LOCK_LOCAL, "depth", 0)): + command = ["env", f"{_GLOBAL_LOCK_ENV}=1", *command] + return command + + +def _recover_hub_pair(channels: list[str]) -> None: + """Recover a silent dual hub: USB reset where installed, else a pair cycle. + + Every recovery cycle of the arm hub is also a moment its firmware can + release frames queued behind an earlier stall (seen 2026-09-22: the right + arm drove to the stalled session's last pose during ``can.setup``). The + reset script clears them first; other hubs, and hosts that have not been + set up with it yet, keep the paired down/up cycle. + """ + if CAN_RESET_SCRIPT.exists() and set(channels) == {_CAN_L, _CAN_R}: + print(" Arm hub: USB reset (drops frames the adapter still holds)...") + run_root(_root_script_command(CAN_RESET_SCRIPT), check=True) + return + bring_up_interfaces(channels, force_cycle=True) + + def _read_root_crontab() -> str: """Read root's crontab, distinguishing an empty table from inspection errors.""" result = run_root(["env", "LC_ALL=C", "crontab", "-l"]) @@ -2226,7 +2330,7 @@ def _bring_up_can_locked(profile: _Profile = _AXOL_PROFILE) -> None: run_root(script_command, check=True) for attempt in range(3): if attempt: - bring_up_interfaces([profile.left, profile.right], force_cycle=True) + _recover_hub_pair([profile.left, profile.right]) left, right = rx_alive_per_arm(profile) if left and right: print(f" Done — motors responding on both {noun}s.") @@ -2367,6 +2471,15 @@ def _flap_for_purge(channels: list[str]) -> None: ``can0``, a host that has never run ``can.setup`` — falls back to :func:`bring_up_interfaces`, which configures each channel explicitly. """ + if ( + CAN_RESET_SCRIPT.exists() + and set(channels) <= _SCRIPT_MANAGED_CHANNELS + and set(channels) & {_CAN_L, _CAN_R} + ): + # Frames on the arm hub may also be parked inside the adapter, where + # only a USB reset reaches them; the script then runs the bring-up. + run_root(["bash", str(CAN_RESET_SCRIPT)], check=True) + return if CAN_BRINGUP_SCRIPT.exists() and set(channels) <= _SCRIPT_MANAGED_CHANNELS: # The script takes its own locks, so this deliberately does not hold # the global setup lock: a caller that did would deadlock it. @@ -2459,6 +2572,7 @@ def _apply_setup( _validate_adapter_assignments(hub_serial, wheels_serial, chest_serial) _write_udev_rules(hub_serial, wheels_serial, chest_serial) _write_cron_script() + _write_reset_script() _write_hotplug_unit() _reload_udev() _rename_interfaces(hub_serial, wheels_serial, chest_serial) diff --git a/almond_axol/constants.py b/almond_axol/constants.py index fa562428..7b4aacd3 100644 --- a/almond_axol/constants.py +++ b/almond_axol/constants.py @@ -46,6 +46,13 @@ class Joint(Enum): # cron/systemd, so it must live outside the operator-writable state tree. CAN_BRINGUP_SCRIPT: Path = Path("/etc/almond-axol/can/startup.sh") +# USB-resets the arm hub, then runs CAN_BRINGUP_SCRIPT. The hub's firmware +# keeps up to 10 frames per channel through the driver's link-down reset and +# transmits them on the next open (kernel: "Unexpected unused echo id"), so a +# flap cannot purge frames queued behind a stalled bus; this is what the +# purge runs instead. Written by `axol can.setup`, root-owned like the above. +CAN_RESET_SCRIPT: Path = Path("/etc/almond-axol/can/reset_adapter.sh") + # Mantis handheld data-collection rig: one dual-channel adapter, each channel # wired to a single Damiao gripper (CAN ID 0x08, same as Joint.GRIPPER). CAN_MANTIS_LEFT = "can_mantis_l" diff --git a/almond_axol/utils/can_purge.py b/almond_axol/utils/can_purge.py index 8e6029e9..9db7df28 100644 --- a/almond_axol/utils/can_purge.py +++ b/almond_axol/utils/can_purge.py @@ -47,6 +47,7 @@ CAN_LEFT, CAN_MANTIS_LEFT, CAN_MANTIS_RIGHT, + CAN_RESET_SCRIPT, CAN_RIGHT, ) from .rtprio import operator_user @@ -92,10 +93,10 @@ def purge_commands() -> list[str]: """Every command line the realtime core runs to purge a poisoned queue. Two callers, one grant: the realtime core's ``purge_tx_queue`` - (``rust/axol-rt/src/safety.rs``) runs the bring-up script, falling back - to a per-interface down/up pair, and the bring-up backstop - (``almond_axol.cli.can.setup.purge_stale_tx``) runs the same script, - falling back to the full configure sequence. + (``rust/axol-rt/src/safety.rs``) runs the arm hub's USB reset script, + else the bring-up script, falling back to a per-interface down/up pair, + and the bring-up backstop (``almond_axol.cli.can.setup.purge_stale_tx``) + runs the same scripts, falling back to the full configure sequence. Imported lazily: ``cli.can.setup`` is a heavy import and this is the only thing needed from it. @@ -103,7 +104,10 @@ def purge_commands() -> list[str]: from ..cli.can.setup import _BITRATE, _TXQUEUELEN commands = [ - f"{bash} {CAN_BRINGUP_SCRIPT}" + f"{bash} {script}" + # The reset script first: the core prefers it for the arm hub, whose + # firmware keeps frames a flap of the bring-up script cannot reach. + for script in (CAN_RESET_SCRIPT, CAN_BRINGUP_SCRIPT) for bash in _program_paths("bash", ("/usr/bin/bash", "/bin/bash")) ] for ip in _program_paths("ip", ("/usr/sbin/ip", "/sbin/ip", "/usr/bin/ip")): diff --git a/docs/cli/can-setup.mdx b/docs/cli/can-setup.mdx index 240d3a86..26b74fe1 100644 --- a/docs/cli/can-setup.mdx +++ b/docs/cli/can-setup.mdx @@ -46,7 +46,9 @@ The Mantis profile gets its own udev rule file, startup script, and hotplug brin The same bring-up script also recovers the bus after an **emergency stop**. Cutting motor power mid-motion leaves the in-flight CAN commands unacknowledged, so the kernel parks up to `txqueuelen` of them on the interface instead of dropping them; without intervention they replay the instant the arm is powered back on and enabled. The arm then jerks to the pose it was *commanded* as power died — not the pose it sagged to once torque was gone, which is why the position it snaps to often looks like nowhere the robot has been. The CAN layer detects the stalled bus, flaps the interfaces to drop the stale frames, and holds every send until a probe frame is acknowledged on the wire again, so the arm stays put until the bus is genuinely back online. - Flapping an interface needs root. The hosted install's `axol serve` is root and has it; a **manual** `axol serve` / `axol teleop` from the operator's shell gets it from the `sudoers.d` drop-in [`axol provision`](/cli/provision) installs for exactly the bring-up script (`almond_axol.utils.can_purge`) — without that grant the purge silently does nothing and the stale frames survive to the next session. As a backstop, every arm bring-up checks each interface's queue before enabling a motor and flaps it if anything is still parked there, refusing to enable rather than replaying it. + On the arm hub a flap is not enough. The hub's firmware keeps the frames it had already accepted from the host — up to 10 per channel, the driver's in-flight limit — through the interface reset and transmits them on the *next* open, possibly minutes later: a new session, or a `can.setup` recovery cycle (the kernel logs `Unexpected unused echo id` for each one). The purge therefore USB-resets the hub instead (`/etc/almond-axol/can/reset_adapter.sh`, written by `can.setup`: `usbreset`, or a sysfs deauthorize/reauthorize where that is missing, then the bring-up script), and `can.setup`'s own recovery cycles of a silent arm hub reset it the same way. The wheel and chest adapters keep the plain flap. Until a robot has re-run `can.setup` and `axol provision` for the script and its grant, the purge falls back to the flap — unplug and replug the hub after any `TX queue stalled` report before running anything that talks to the arms. + + Flapping an interface needs root. The hosted install's `axol serve` is root and has it; a **manual** `axol serve` / `axol teleop` from the operator's shell gets it from the `sudoers.d` drop-in [`axol provision`](/cli/provision) installs for exactly the reset and bring-up scripts (`almond_axol.utils.can_purge`) — without that grant the purge silently does nothing and the stale frames survive to the next session. As a backstop, every arm bring-up checks each interface's queue before enabling a motor and flaps it if anything is still parked there, refusing to enable rather than replaying it. A **momentary** TX-queue overflow is handled more gently. Both arm channels share one dual-channel USB adapter, so a brief stall of its USB pipe — e.g. camera traffic during data collection — can back the queue up while the bus itself is healthy and draining. Told apart from a dead bus by how long it persists, a brief overflow just drops the frame (a rate-limited warning) and the next command proceeds, so heavy USB traffic no longer crashes teleop or data collection mid-session; only an overflow that keeps persisting (nothing draining) escalates to the interface flap and probe-gated hold above. diff --git a/docs/cli/provision.mdx b/docs/cli/provision.mdx index 7f33aa4f..cd85ac0c 100644 --- a/docs/cli/provision.mdx +++ b/docs/cli/provision.mdx @@ -9,7 +9,7 @@ Installs and refreshes the pieces `uv tool install` can't manage on its own: - [`tracker.install`](/cli/tracker) — a pinned **libsurvive** build and Vive USB permissions for Mantis Lighthouse tracking. - [`zed.driver`](/cli/zed-driver) — replaces a ZED Box's outdated factory **GMSL camera driver** (`stereolabs-zedbox-duo` on the Duo, `stereolabs-zedbox-mini` on the Mini) with the release pinned for the ZED SDK. Takes effect on the next reboot (prints a notice; never reboots itself). A no-op off a factory-flashed ZED Box; warns about a Stereolabs driver package it has no pin for. - **board IMU** (`gyro.install`) — grants the `imu` group access to the carrier board's BMI088 sampling timer, [Jelly](/guides/vr-interface#jelly)'s heading-hold yaw reference, so teleop can start it without root. Self-gates on the board driver's presence. -- **CAN e-stop purge grant** — writes `/etc/sudoers.d/50-axol-can-purge` so a manual `axol serve` / `axol teleop` can flap a CAN interface without a password when motor power dies. The realtime core purges the interface's queued position commands the moment it declares the bus stalled; without this grant that escalation fails on a non-root session, the frames survive, and the arm jerks to its pre-e-stop command on the next bring-up (see [`can.setup`](/cli/can-setup)). The rule covers only the root-owned bring-up script and the named CAN interfaces, and is checked with `visudo` before it is installed. Takes effect immediately. +- **CAN e-stop purge grant** — writes `/etc/sudoers.d/50-axol-can-purge` so a manual `axol serve` / `axol teleop` can flap a CAN interface without a password when motor power dies. The realtime core purges the interface's queued position commands the moment it declares the bus stalled; without this grant that escalation fails on a non-root session, the frames survive, and the arm jerks to its pre-e-stop command on the next bring-up (see [`can.setup`](/cli/can-setup)). The rule covers only the root-owned reset and bring-up scripts (the arm hub is USB-reset, since its firmware keeps frames a flap cannot reach) and the named CAN interfaces, and is checked with `visudo` before it is installed. Takes effect immediately. - **real-time scheduling grant** — writes `/etc/security/limits.d/50-axol-rtprio.conf` so your login can run the control loop and the camera relay's capture chain `SCHED_FIFO` from a manual `axol serve` / `axol teleop` (the systemd unit already has `LimitRTPRIO`). Login shells otherwise start with an `rtprio` limit of zero, and then: - **the control loop refuses to start** — `axol teleop`, `collect-data`, `collect-dagger` and `run-policy` raise `refusing to run without real-time scheduling` rather than run a loop that lands roughly one tick in fifty 15–65 ms late (felt as the arms hitching and lunging, and masked by turning the cameras off). Set `AXOL_ALLOW_CFS_CONTROL=1` to run degraded anyway. - **the relay silently falls back to CFS** — `camera capture threads stay SCHED_OTHER` at startup, and recordings under load discard episodes on skipped exposures. diff --git a/rust/axol-rt/src/safety.rs b/rust/axol-rt/src/safety.rs index 1e4838ae..a5e3e44f 100644 --- a/rust/axol-rt/src/safety.rs +++ b/rust/axol-rt/src/safety.rs @@ -24,6 +24,15 @@ const BRINGUP_SCRIPT: &str = "/etc/almond-axol/can/startup.sh"; /// `/etc`. Still honoured so a purge works on a robot that has not been /// re-provisioned yet; `axol provision` deletes the root references to it. const LEGACY_BRINGUP_SCRIPT: &str = ".almond/can/startup.sh"; +/// The arm hub's USB reset — `almond_axol.constants.CAN_RESET_SCRIPT`. The +/// hub firmware keeps the frames it already accepted (up to the driver's 10 +/// in flight per channel) through a link down/up and transmits them on the +/// next open, so on the arm buses a flap only defers the replay; this script +/// resets the device, then runs the bring-up script. Same grant as above. +const RESET_SCRIPT: &str = "/etc/almond-axol/can/reset_adapter.sh"; +/// The two channels of the arm hub (`CAN_LEFT` / `CAN_RIGHT` in +/// `almond_axol.constants`), the only interfaces the reset script covers. +const ARM_HUB_IFACES: [&str; 2] = ["can_alm_axol_l", "can_alm_axol_r"]; fn is_tx_full(err: &io::Error) -> bool { matches!(err.raw_os_error(), Some(libc::ENOBUFS) | Some(libc::EAGAIN)) @@ -99,10 +108,14 @@ fn bringup_script() -> Option { bringup_script_in(Path::new(BRINGUP_SCRIPT), home.as_deref()) } -/// Drop frames queued behind a dead bus by flapping the CAN interface. +/// Drop frames queued behind a dead bus. /// -/// Prefer the installed bring-up script because the dual-channel adapter is -/// most reliable when both channels are flapped together. A purge performed +/// On an arm-hub channel, USB-reset the hub first (`RESET_SCRIPT`): a flap +/// clears the kernel's queue but not the frames the hub firmware already +/// holds, which it transmits on the next open. Otherwise, or when the reset +/// cannot run, flap the interface — preferring the installed bring-up script +/// because the dual-channel adapter is most reliable when both channels are +/// flapped together. A purge performed /// for the other arm within the last three seconds counts for this arm too. /// /// Returns false when the flap could not be run at all — most often because @@ -113,10 +126,19 @@ fn bringup_script() -> Option { /// (`almond_axol.cli.can.setup.purge_stale_tx`). pub fn purge_tx_queue(iface: &str) -> bool { let mut last = LAST_PURGE.lock().unwrap(); + let reset = reset_script_for(iface, Path::new(RESET_SCRIPT)); let script = bringup_script(); - if script.is_some() && last.is_some_and(|t| t.elapsed() < PURGE_DEDUPE) { + if (reset.is_some() || script.is_some()) && last.is_some_and(|t| t.elapsed() < PURGE_DEDUPE) { return true; } + if let Some(path) = &reset { + // Falls through to the flap when the reset cannot run — most often a + // robot whose sudo grant predates the script (`axol provision`). + if run_root(&["bash", &path.to_string_lossy()]).is_ok_and(|st| st.success()) { + *last = Some(Instant::now()); + return true; + } + } let result = match &script { Some(path) => run_root(&["bash", &path.to_string_lossy()]), None => run_root(&["ip", "link", "set", iface, "down"]).and_then(|st| { @@ -136,6 +158,12 @@ pub fn purge_tx_queue(iface: &str) -> bool { } } +/// The reset script to purge `iface` with: only for an arm-hub channel, and +/// only once `axol can.setup` has installed it. Split out for the tests. +fn reset_script_for(iface: &str, provisioned: &Path) -> Option { + (ARM_HUB_IFACES.contains(&iface) && provisioned.is_file()).then(|| provisioned.to_path_buf()) +} + #[cfg(test)] mod tests { use super::*; @@ -185,4 +213,29 @@ mod tests { assert_eq!(bringup_script_in(&dir.join("absent.sh"), Some(&dir)), None); assert_eq!(bringup_script_in(&dir.join("absent.sh"), None), None); } + + #[test] + fn arm_hub_channels_purge_with_the_usb_reset() { + let dir = scratch("reset"); + let reset = dir.join("reset_adapter.sh"); + std::fs::write(&reset, "#!/bin/bash\n").unwrap(); + for iface in ARM_HUB_IFACES { + assert_eq!(reset_script_for(iface, &reset), Some(reset.clone())); + } + } + + #[test] + fn other_buses_and_unset_up_hosts_keep_the_flap() { + // The wheel/chest adapters are single-channel and not the hub; a + // reset of the hub for their stall would drop a healthy arm session. + let dir = scratch("noreset"); + let reset = dir.join("reset_adapter.sh"); + std::fs::write(&reset, "#!/bin/bash\n").unwrap(); + assert_eq!(reset_script_for("can_alm_axol_b", &reset), None); + assert_eq!(reset_script_for("can0", &reset), None); + assert_eq!( + reset_script_for("can_alm_axol_l", &dir.join("absent.sh")), + None + ); + } } diff --git a/tests/test_can_purge.py b/tests/test_can_purge.py index 8bd9d014..8de2a7bb 100644 --- a/tests/test_can_purge.py +++ b/tests/test_can_purge.py @@ -19,7 +19,13 @@ from unittest.mock import AsyncMock, patch from almond_axol.cli.can import setup as can_setup -from almond_axol.constants import CAN_BRINGUP_SCRIPT, CAN_LEFT, CAN_RIGHT +from almond_axol.constants import ( + CAN_BASE, + CAN_BRINGUP_SCRIPT, + CAN_LEFT, + CAN_RESET_SCRIPT, + CAN_RIGHT, +) from almond_axol.utils import can_purge @@ -51,6 +57,15 @@ def test_grants_the_exact_commands_the_core_runs(self) -> None: for command in commands: self.assertTrue(command.startswith("/"), command) + def test_grants_the_arm_hub_usb_reset(self) -> None: + # safety.rs prefers the reset script on the arm channels: the hub + # firmware keeps frames a flap cannot reach. + commands = can_purge.purge_commands() + self.assertTrue( + any(command.endswith(f"bash {CAN_RESET_SCRIPT}") for command in commands), + commands, + ) + def test_grants_every_form_the_fallback_flap_issues(self) -> None: # bring_up_interfaces configures between the down and the up. A grant # covering only down/up lets a non-root backstop take the interfaces @@ -176,6 +191,16 @@ def test_unknown_when_tc_is_missing_or_fails(self) -> None: class PurgeStaleTxTest(unittest.TestCase): """What a bring-up does about frames the last session left queued.""" + def setUp(self) -> None: + # Neither generated script is installed, whatever this host (a robot + # included) has in /etc: the flap goes through bring_up_interfaces. + scratch = tempfile.TemporaryDirectory() + self.addCleanup(scratch.cleanup) + for name in ("CAN_BRINGUP_SCRIPT", "CAN_RESET_SCRIPT"): + patcher = patch.object(can_setup, name, Path(scratch.name) / name) + patcher.start() + self.addCleanup(patcher.stop) + def test_clean_queues_flap_nothing(self) -> None: with ( patch.object(can_setup, "_iface_present", return_value=True), @@ -311,11 +336,46 @@ def setUp(self) -> None: scratch = tempfile.TemporaryDirectory() self.addCleanup(scratch.cleanup) self.script = Path(scratch.name) / "startup.sh" + self.reset = Path(scratch.name) / "reset_adapter.sh" + # Absent unless a test installs it, whatever this host has in /etc. + patcher = patch.object(can_setup, "CAN_RESET_SCRIPT", self.reset) + patcher.start() + self.addCleanup(patcher.stop) def _installed(self) -> Path: self.script.write_text("#!/bin/bash\n") return self.script + def test_arm_channels_prefer_the_usb_reset_once_installed(self) -> None: + self.reset.write_text("#!/bin/bash\n") + runs: list[list[str]] = [] + with ( + patch.object(can_setup, "CAN_BRINGUP_SCRIPT", self._installed()), + patch.object( + can_setup, + "run_root", + lambda argv, **_kw: runs.append(argv) or _completed(0), + ), + patch.object(can_setup, "bring_up_interfaces", side_effect=AssertionError), + ): + can_setup._flap_for_purge([CAN_LEFT, CAN_RIGHT]) + self.assertEqual(runs, [["bash", str(self.reset)]]) + + def test_a_single_bus_keeps_the_bring_up_flap(self) -> None: + # The reset covers only the arm hub; the wheel bus is its own adapter. + self.reset.write_text("#!/bin/bash\n") + runs: list[list[str]] = [] + with ( + patch.object(can_setup, "CAN_BRINGUP_SCRIPT", self._installed()), + patch.object( + can_setup, + "run_root", + lambda argv, **_kw: runs.append(argv) or _completed(0), + ), + ): + can_setup._flap_for_purge([CAN_BASE]) + self.assertEqual(runs, [["bash", str(self.script)]]) + def test_prefers_the_bring_up_script_for_managed_channels(self) -> None: # One granted command, and the only ordering that takes the dual # adapter's two channels down and up together. diff --git a/tests/test_can_setup.py b/tests/test_can_setup.py index 211fe9f7..cd9107f2 100644 --- a/tests/test_can_setup.py +++ b/tests/test_can_setup.py @@ -3,6 +3,7 @@ import contextlib import io import os +import subprocess import tempfile import unittest from contextlib import redirect_stderr, redirect_stdout @@ -405,12 +406,16 @@ def test_paired_recovery_takes_both_channels_down_before_either_up(self) -> None ) def test_axol_rx_retry_does_not_reset_wheel_and_lift_buses_again(self) -> None: + # A host without the reset script: the retry is the plain pair cycle. + no_reset = Mock() + no_reset.exists.return_value = False with ( patch.object( setup, "_global_setup_lock", return_value=contextlib.nullcontext() ), patch.object(setup._LOCK_LOCAL, "depth", 1, create=True), patch.object(setup.Path, "exists", return_value=True), + patch.object(setup, "CAN_RESET_SCRIPT", no_reset), patch.object(setup, "run_root") as run_root, patch.object( setup, @@ -435,6 +440,80 @@ def test_axol_rx_retry_does_not_reset_wheel_and_lift_buses_again(self) -> None: [setup.CAN_LEFT, setup.CAN_RIGHT], force_cycle=True ) + def test_axol_rx_retry_usb_resets_the_hub_once_installed(self) -> None: + # The hub firmware can release frames it held from a stalled session + # on any pair cycle; the retry resets the device instead. + with ( + patch.object( + setup, "_global_setup_lock", return_value=contextlib.nullcontext() + ), + patch.object(setup._LOCK_LOCAL, "depth", 1, create=True), + patch.object(setup.Path, "exists", return_value=True), + patch.object(setup, "run_root") as run_root, + patch.object( + setup, + "rx_alive_per_arm", + side_effect=[(False, False), (True, True)], + ), + patch.object(setup, "bring_up_interfaces", side_effect=AssertionError), + contextlib.redirect_stdout(io.StringIO()), + ): + setup.bring_up_can(setup._AXOL_PROFILE) + + flagged = ["env", f"{setup._GLOBAL_LOCK_ENV}=1", "bash"] + self.assertEqual( + run_root.call_args_list, + [ + call([*flagged, str(setup._AXOL_PROFILE.cron_script)], check=True), + call([*flagged, str(setup.CAN_RESET_SCRIPT)], check=True), + ], + ) + + def test_only_the_arm_hub_pair_is_usb_reset(self) -> None: + with ( + patch.object(setup.Path, "exists", return_value=True), + patch.object(setup, "run_root", side_effect=AssertionError), + patch.object(setup, "bring_up_interfaces") as bring_up, + contextlib.redirect_stdout(io.StringIO()), + ): + setup._recover_hub_pair([setup.CAN_MANTIS_LEFT, setup.CAN_MANTIS_RIGHT]) + bring_up.assert_called_once_with( + [setup.CAN_MANTIS_LEFT, setup.CAN_MANTIS_RIGHT], force_cycle=True + ) + + def test_reset_script_resets_the_hub_then_runs_the_bring_up(self) -> None: + text = setup._reset_script_text() + self.assertTrue(text.startswith("#!/bin/bash\n")) + self.assertIn("set -euo pipefail", text) + # The hub behind the arm channels, found through sysfs, reset once. + self.assertIn("/sys/class/net/${IFACE}/device/..", text) + self.assertIn(f"for IFACE in {setup.CAN_LEFT} {setup.CAN_RIGHT}; do", text) + self.assertIn('usbreset "$(printf "%03d/%03d"', text) + # The sysfs fallback where usbreset is missing or fails. + self.assertIn('echo 0 > "${HUB}/authorized"', text) + self.assertIn('echo 1 > "${HUB}/authorized"', text) + # Lock handling matches the bring-up script, which it hands over to + # with the global lock still held. + self.assertIn(f'exec 8<"{setup._GLOBAL_LOCK_FILE}"', text) + self.assertTrue( + text.rstrip().endswith( + f"exec env {setup._GLOBAL_LOCK_ENV}=1 bash " + f'"{setup._AXOL_PROFILE.cron_script}"' + ) + ) + # The single-channel wheel/chest adapters are never reset. + self.assertNotIn(setup.CAN_BASE, text) + self.assertNotIn(setup.CAN_CHEST, text) + + def test_reset_script_passes_bash_syntax_check(self) -> None: + with tempfile.NamedTemporaryFile("w", suffix=".sh") as script: + script.write(setup._reset_script_text()) + script.flush() + checked = subprocess.run( + ["bash", "-n", script.name], capture_output=True, text=True + ) + self.assertEqual(checked.returncode, 0, checked.stderr) + def test_root_executed_scripts_install_root_owned_outside_operator_state( self, ) -> None: @@ -612,6 +691,7 @@ def test_apply_setup_does_not_swallow_rp1_security_cleanup_failure(self) -> None with ( patch.object(setup, "_write_udev_rules"), patch.object(setup, "_write_cron_script"), + patch.object(setup, "_write_reset_script"), patch.object(setup, "_write_hotplug_unit"), patch.object(setup, "_reload_udev"), patch.object(setup, "_rename_interfaces"), From 653d7b84920111c49474102e93dfb4d4d0bbdfa0 Mon Sep 17 00:00:00 2001 From: Shawn Patel Date: Tue, 22 Sep 2026 22:10:17 +0000 Subject: [PATCH 47/80] Firmware-loop tab: gain boxes follow the joint's motor, no sliders The tab offered MyActuator's whole knob set for every joint, so a wrist run could be launched with position_kd / current_* / planner accel that tune.a4 refuses for a Damiao motor, and hid nothing of ACC/DEC for the MyActuator joints. Fields now carry the vendors whose loop has them (lib/firmware-loop): position_kd, current_kp/ki and planner accel are MyActuator-only, ACC/DEC is Damiao-only, position_kp/ki and speed_kp/ki are shared. A hidden field's typed value stays in the form but is never sent. With no joint picked every field shows. The firmware gains are plain number boxes now: the sliders' ranges were MyActuator's (position_kp 0-0.3) and cannot also cover a Damiao KP_APR in the hundreds. Empty boxes show the motor's live value as their placeholder. The impedance tabs keep their sliders. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../diagnostics/tuning-workbench.tsx | 313 +++++++++--------- web/app/src/lib/firmware-loop.test.ts | 30 ++ web/app/src/lib/firmware-loop.ts | 39 +++ 3 files changed, 234 insertions(+), 148 deletions(-) create mode 100644 web/app/src/lib/firmware-loop.test.ts create mode 100644 web/app/src/lib/firmware-loop.ts diff --git a/web/app/src/components/diagnostics/tuning-workbench.tsx b/web/app/src/components/diagnostics/tuning-workbench.tsx index 338744f1..a86dbf4e 100644 --- a/web/app/src/components/diagnostics/tuning-workbench.tsx +++ b/web/app/src/components/diagnostics/tuning-workbench.tsx @@ -5,6 +5,7 @@ import { Button } from "@/components/ui/button" import { Card } from "@/components/ui/card" import { useToast } from "@/components/ui/toast" import { cn } from "@/lib/utils" +import { type FirmwareVendor, shownForJoint } from "@/lib/firmware-loop" import { RunChart, type RunChartSeries } from "@/components/diagnostics/run-chart" import type { CommandSpec, FormValue } from "@/lib/supervisor" import { @@ -85,6 +86,11 @@ interface WbField { fwGainKey?: string /** Render a slider next to the value box, over this range. */ slider?: { min: number; max: number; step: number } + /** + * The motor vendors whose firmware loop has this knob; the field hides for + * a joint of any other vendor (see `lib/firmware-loop`). Unset = always. + */ + vendors?: readonly FirmwareVendor[] } interface WbTab { @@ -155,9 +161,11 @@ const GAIN_FIELDS: WbField[] = [ /** * The firmware loop gains of the Firmware-loop tab. Each shows the selected * motor's *live* value ("motor N", read over the idle link when arm and joint - * are picked) and a slider seeded there; an empty box runs with the motor's - * value. Ranges are deliberately tight: on the X8 shoulders 3× the stock - * speed_kp already vibrated, so a sweep steps in small increments. + * are picked); an empty box runs with the motor's value. Plain number boxes, + * no sliders: the two vendors' gains live on different scales (a MyActuator + * position_kp near 1, a Damiao KP_APR in the hundreds), so no one range fits. + * Fields tagged with `vendors` show only for a joint on that vendor's motor — + * the Damiao wrists have no position D, no exposed current loop. */ const FW_GAIN_FIELDS: WbField[] = [ { @@ -165,58 +173,56 @@ const FW_GAIN_FIELDS: WbField[] = [ label: "position_kp", type: "text", fwGainKey: "position_kp", - slider: { min: 0, max: 0.3, step: 0.001 }, - hint: "firmware position loop P — lag ∝ 1/kp; X8 shoulders read 0.008, elbow 0.06", + hint: + "position loop P — lag ∝ 1/kp. MyActuator: config 1.0 (elbow 1.4), stock 0.008 " + + "on the X8 shoulders. Damiao KP_APR: config 400", }, { key: "position_ki", label: "position_ki", type: "text", fwGainKey: "position_ki", - slider: { min: 0, max: 0.02, step: 0.0001 }, - hint: "firmware position loop I", + hint: "position loop I (Damiao KI_APR)", }, { key: "position_kd", label: "position_kd", type: "text", fwGainKey: "position_kd", - slider: { min: 0, max: 2, step: 0.01 }, - hint: "firmware position loop D", + vendors: ["myactuator"], + hint: "position loop D — measured inert in the 0xA4 loop on the X8-P20", }, { key: "speed_kp", label: "speed_kp", type: "text", fwGainKey: "speed_kp", - slider: { min: 0, max: 0.15, step: 0.001 }, hint: - "firmware speed loop P — the loop that cycles at creep; 0.1 vibrated on shoulder_1 " + - "(stock 0.03)", + "speed loop P (Damiao KP_ASR) — on MyActuator the only damping term and the " + + "buzz knob; 0.1 vibrated on shoulder_1 (stock 0.03)", }, { key: "speed_ki", label: "speed_ki", type: "text", fwGainKey: "speed_ki", - slider: { min: 0, max: 0.005, step: 0.00005 }, - hint: "firmware speed loop I — what pushes through stiction", + hint: "speed loop I (Damiao KI_ASR) — what pushes through stiction", }, { key: "current_kp", label: "current_kp", type: "text", fwGainKey: "current_kp", - slider: { min: 0, max: 2, step: 0.01 }, - hint: "firmware current loop P — leave unless the vendor says otherwise", + vendors: ["myactuator"], + hint: "current loop P — leave unless the vendor says otherwise", }, { key: "current_ki", label: "current_ki", type: "text", fwGainKey: "current_ki", - slider: { min: 0, max: 0.5, step: 0.001 }, - hint: "firmware current loop I", + vendors: ["myactuator"], + hint: "current loop I", }, ] @@ -338,8 +344,9 @@ const TABS: WbTab[] = [ label: "Firmware loop", command: "tune.a4", description: - "Tune a MyActuator joint's own position loop (0xA4, the controller " + - "behind wire_mode a4) with a sine or a constant-speed triangle. Firmware " + + "Tune a joint's own firmware position loop — 0xA4 on the MyActuator joints " + + "(wire_mode a4), position-velocity on the Damiao wrists (pv) — with a sine or " + + "a constant-speed triangle; the gain boxes follow the joint's motor. Firmware " + "gains are written to RAM for the run and restored afterwards (persist " + "writes ROM, keep leaves them); planner acceleration must be 0 for the " + "joint to follow a stream. A buzz guard restores the previous gains on " + @@ -400,9 +407,10 @@ const TABS: WbTab[] = [ }, { key: "dm_acc", - label: "Damiao ACC/DEC (rad/s²)", + label: "ACC/DEC (rad/s²)", type: "number", placeholder: "stored", + vendors: ["damiao"], hint: "wrist_2 / wrist_3 only: the position-velocity profiler's acceleration (and " + "-deceleration), written to the registers for the run and restored afterwards " + @@ -413,6 +421,7 @@ const TABS: WbTab[] = [ label: "planner accel (dps/s)", type: "text", fwGainKey: "planner_accel", + vendors: ["myactuator"], width: "w-24", hint: "shows what the motor stores; 0 = direct PI tracking (required to follow the " + @@ -2517,6 +2526,9 @@ export function TuningWorkbench({ if (miss.length > 0) return const args: Record = { ...tab.presets } for (const f of tab.fields) { + // A value typed for the other vendor's knob stays in the form (it + // comes back if the joint does) but is never sent: tune.a4 refuses it. + if (!shownForJoint(f.vendors, tabValues["joint"])) continue const raw = (tabValues[f.key] ?? "").trim() if (!raw) continue args[f.key] = f.type === "boolean" ? raw === "true" : raw @@ -2693,137 +2705,142 @@ export function TuningWorkbench({

{tab.description}

- {tab.fields.map((f) => { - const cfg = configValue(f) - return ( - - ) - })} + )} + + ) + })}
{runningThisTab ? (

- The wrist ZED X One's IMU during the run: acceleration band-passed to 3–15 Hz + The wrist ZED X One's IMU during the run: acceleration band-passed to 1–15 Hz (above the motion, below the buzz), integrated to displacement, and scored as the median - 1 s peak-to-peak excursion at the gripper — overall and along gravity (vertical). Unlike - the joint scores it sees backlash, link flex and the gripper itself. + 2 s peak-to-peak excursion at the gripper — overall and along gravity (vertical), the + vertical split into 1–3 Hz (the impedance sway) and 3–15 Hz. Unlike the joint scores it + sees backlash, link flex and the gripper itself.

)} @@ -3668,7 +3675,7 @@ export function TuningWorkbench({ {imuHeadline(r) && ( IMU {imuHeadline(r)} From 8b7573981965761a257b072f573b9dbc02fd0c75 Mon Sep 17 00:00:00 2001 From: Shawn Patel Date: Thu, 24 Sep 2026 00:33:59 +0000 Subject: [PATCH 74/80] tune.motion --gain: the gravity model's link mass and com.x/y/z MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit For trying a gravity correction as a run before committing it to calibration (e.g. right.wrist_3.mass=0.86 — the ~0.11 kg the jelly robot's replay torques say the wrist carries beyond the model, likely the wrist camera and mount). Co-Authored-By: Claude Opus 5.5 (1M context) --- almond_axol/cli/tune/motion.py | 13 ++++++++++++- docs/cli/tune-motion.mdx | 2 +- tests/test_controller_option.py | 11 +++++++++++ 3 files changed, 24 insertions(+), 2 deletions(-) diff --git a/almond_axol/cli/tune/motion.py b/almond_axol/cli/tune/motion.py index 05a24a51..c503cc5b 100644 --- a/almond_axol/cli/tune/motion.py +++ b/almond_axol/cli/tune/motion.py @@ -128,6 +128,13 @@ "firmware.tf_rated_current_a", # The cogging ("osc") cancellation's share of the calibrated series. "cogging_gain", + # The gravity model's per-link inertials (the body this joint drives): + # mass (kg) and centre of mass (m, URDF link frame) — for trying a gravity + # correction before committing it to calibration. + "mass", + "com.x", + "com.y", + "com.z", ) # Column names of a 14-wide motion row: left arm then right arm. @@ -263,7 +270,7 @@ def _parse_gain_overrides(specs: list[str]) -> dict[tuple[str, str, str], float] raise SystemExit(f"--gain: bad value in {spec!r} (want PATH=NUMBER)") # ``[side.]joint.friction.fc`` / ``joint.firmware.speed_kp``: fold the # sub-field back into one token. - if len(parts) >= 2 and parts[-2] in ("friction", "firmware"): + if len(parts) >= 2 and parts[-2] in ("friction", "firmware", "com"): parts = parts[:-2] + [f"{parts[-2]}.{parts[-1]}"] if len(parts) == 3: sides, joint, fld = [parts[0]], parts[1], parts[2] @@ -328,6 +335,10 @@ def _apply_gain_overrides( target.friction = replace(target.friction, **{fld.split(".", 1)[1]: value}) elif fld.startswith("firmware."): target.firmware = replace(target.firmware, **{fld.split(".", 1)[1]: value}) + elif fld.startswith("com."): + com = list(target.com) + com["xyz".index(fld[-1])] = value + target.com = tuple(com) else: setattr(target, fld, value) diff --git a/docs/cli/tune-motion.mdx b/docs/cli/tune-motion.mdx index 8ef454ba..c9b4bdea 100644 --- a/docs/cli/tune-motion.mdx +++ b/docs/cli/tune-motion.mdx @@ -12,7 +12,7 @@ The arm moves to the motion's start and back to rest on collision-aware planned | Flag | Description | |---|---| | `--motion NAME` | Committed motion name (`axol motion.list`) or a path to a motion `.npz` (required) | -| `--gain [SIDE.]JOINT.FIELD=VALUE` | Override one gain for this run, e.g. `left.elbow.kd=4.5` or `shoulder_3.kd_host=8` (no side = both arms). Fields: `kp`, `kd`, `kd_host`, `kd_host_hz`, `kd_host_q`, `j_eff`, `stiction_gain`, `stiction_load_gain`, `stiction_err_deg`, `dither_nm`, `dither_hz`, `stribeck_gain`, `stribeck_dfs`, `stribeck_load_gain`, `stribeck_vs`, `stribeck_pole`, the friction model as `friction.fc`, `friction.k`, `friction.fv`, `friction.fo`, `friction.fl`, and the firmware position-loop gains as `firmware.position_kp`, `firmware.position_ki`, `firmware.position_kd`, `firmware.speed_kp`, `firmware.speed_ki`, `firmware.profile_acc`, the MyActuator 0xA4 planner as `firmware.planner_accel` (`0` or `60000`) with its per-tick speed-cap tracking `firmware.cap_track` (≥ 1, e.g. `1.2`) and target lead `firmware.planner_lead_ms`, the 0x73 torque feed-forward's `firmware.tf_rated_current_a` (A; MyActuator a4 joints on V4.4 firmware), and the cogging cancellation's share `cogging_gain` (written to the motors' ROM at enable like the config values they replace, so a run leaves them there — the planner is pinned back to `0` by the next run without the override). Repeatable | +| `--gain [SIDE.]JOINT.FIELD=VALUE` | Override one gain for this run, e.g. `left.elbow.kd=4.5` or `shoulder_3.kd_host=8` (no side = both arms). Fields: `kp`, `kd`, `kd_host`, `kd_host_hz`, `kd_host_q`, `j_eff`, `stiction_gain`, `stiction_load_gain`, `stiction_err_deg`, `dither_nm`, `dither_hz`, `stribeck_gain`, `stribeck_dfs`, `stribeck_load_gain`, `stribeck_vs`, `stribeck_pole`, the friction model as `friction.fc`, `friction.k`, `friction.fv`, `friction.fo`, `friction.fl`, and the firmware position-loop gains as `firmware.position_kp`, `firmware.position_ki`, `firmware.position_kd`, `firmware.speed_kp`, `firmware.speed_ki`, `firmware.profile_acc`, the MyActuator 0xA4 planner as `firmware.planner_accel` (`0` or `60000`) with its per-tick speed-cap tracking `firmware.cap_track` (≥ 1, e.g. `1.2`) and target lead `firmware.planner_lead_ms`, the 0x73 torque feed-forward's `firmware.tf_rated_current_a` (A; MyActuator a4 joints on V4.4 firmware), , the cogging cancellation's share `cogging_gain`, and the gravity model's link inertials `mass` (kg) and `com.x` / `com.y` / `com.z` (m, the driven link's URDF frame — for trying a gravity correction before calibrating it) (firmware gains are written to the motors' ROM at enable like the config values they replace, so a run leaves them there — the planner is pinned back to `0` by the next run without the override). Repeatable | | `--stiffness S` | Stiffness-slider position in `[0, 1]` for both arms (default: 1.0, the production default — the tuned gains, where gain overrides land exactly; lower only adds compliance) | | `--ik` | Drive the run through the IK solver: each waypoint's end-effector poses (FK of the reference, with elbow hints) are re-solved to joints exactly like teleop's pose→joints loop, and the arms execute the *solver's* output — still scored against the clean reference, so IK reconstruction error and tracking error show up together. Per-solve times and the solved-vs-reference deviation are stored on the run (`ik_solve_ms_*`, `ik_dev_*`) | | `--noise MODE` | Corrupt the motion before streaming it, at the noise source's real pipeline entry point: `network` (jitter/outliers/stalls), `ik` (solver churn/jumps), `combined`, or `none` (default). Deterministic per `--seed` | diff --git a/tests/test_controller_option.py b/tests/test_controller_option.py index 97307e04..ffff98f0 100644 --- a/tests/test_controller_option.py +++ b/tests/test_controller_option.py @@ -492,6 +492,17 @@ def test_impedance_rate_flag_and_the_new_gain_fields(self) -> None: self.assertEqual(cfg.right.shoulder_1.firmware.tf_rated_current_a, 12.0) self.assertIsNone(cfg.right.shoulder_2.firmware.tf_rated_current_a) self.assertEqual(cfg.right.shoulder_1.cogging_gain, 0.5) + grav = tune_motion._parse_gain_overrides( + ["right.shoulder_3.com.z=-0.17", "right.wrist_3.mass=0.9"] + ) + cfg2 = AxolConfig() + tune_motion._apply_gain_overrides(cfg2, grav) + self.assertEqual(cfg2.right.shoulder_3.com[2], -0.17) + self.assertEqual( + cfg2.right.shoulder_3.com[:2], AxolConfig().right.shoulder_3.com[:2] + ) + self.assertEqual(cfg2.left.shoulder_3.com, AxolConfig().left.shoulder_3.com) + self.assertEqual(cfg2.right.wrist_3.mass, 0.9) for spec, message in { "right.wrist_2.firmware.tf_rated_current_a=3": "Damiao wrist", "right.elbow.firmware.tf_rated_current_a=0": "> 0", From 4bf490dca41e4a8c05b84396b9a96c8c3d68e754 Mon Sep 17 00:00:00 2001 From: Shawn Patel Date: Thu, 24 Sep 2026 01:01:01 +0000 Subject: [PATCH 75/80] Bring-up writes planner_accel only to joints on the 0xA4 loop Every impedance replay on the jelly robot's right arm pinned all five MyActuator motors' planner to 0 (the X8 shoulders, firmware 2026042403, keep a decel floor: 0 / 10 dps/s), because apply_firmware_gains wrote the config's planner_accel whatever the joint's wire mode. The planner shapes nothing the core sends an impedance joint, but the tuners' single-target 0xA4 moves (tune.friction / tune.breakaway homing) depend on it, and tune.breakaway's homing went wild (2026-09-24). The planner is now written, and checked on held joints, only where wire_mode is a4. Co-Authored-By: Claude Opus 5.5 (1M context) --- almond_axol/robot/axol.py | 25 +++++++++++++++++---- tests/test_firmware_gains.py | 42 +++++++++++++++++++++++++++++++----- 2 files changed, 58 insertions(+), 9 deletions(-) diff --git a/almond_axol/robot/axol.py b/almond_axol/robot/axol.py index 7e4bc2b4..9e76bba7 100644 --- a/almond_axol/robot/axol.py +++ b/almond_axol/robot/axol.py @@ -158,6 +158,25 @@ async def _arm_is_unpowered(arm: "AxolArm", bus: CanBus) -> bool: ) +def _wanted_firmware(jc: object) -> dict[str, float]: + """The firmware parameters a bring-up should put on this joint's motor. + + ``planner_accel`` only when the joint actually runs on the 0xA4 loop + (``wire_mode`` ``a4``): the 0xA4 stream needs it at 0 or 60000, but on an + impedance joint the planner shapes nothing the core sends — and every + single-target position move the tuners make (``tune.friction`` / + ``tune.breakaway`` homing, one 0xA4 target at 14 deg/s) relies on it. The + jelly robot's right arm, pinned to 0 by every impedance replay, went wild + in ``tune.breakaway``'s homing (2026-09-24): its X8 shoulders (firmware + 2026042403) refuse a deceleration of 0 and sat at 0 / 10 dps/s. + """ + firmware = getattr(jc, "firmware", None) + wanted = firmware.as_dict() if firmware is not None else {} + if str(getattr(jc, "wire_mode", "mit")).lower() != "a4": + wanted.pop("planner_accel", None) + return wanted + + async def apply_firmware_gains(arm: "AxolArm", joints: Iterable[Joint]) -> None: """Write the configured firmware loop gains of ``joints`` to their motors' ROM. @@ -191,8 +210,7 @@ async def apply_firmware_gains(arm: "AxolArm", joints: Iterable[Joint]) -> None: side = "left" if getattr(arm, "_is_left", True) else "right" for joint in joints: jc = getattr(arm_config, joint.value, None) - firmware = getattr(jc, "firmware", None) - wanted = firmware.as_dict() if firmware is not None else {} + wanted = _wanted_firmware(jc) if not wanted: continue driver = getattr(arm.motors.get(joint), "_driver", None) @@ -256,8 +274,7 @@ async def held_firmware_gain_mismatches( side = "left" if getattr(arm, "_is_left", True) else "right" out: list[str] = [] for joint in joints: - firmware = getattr(getattr(arm_config, joint.value, None), "firmware", None) - wanted = firmware.as_dict() if firmware is not None else {} + wanted = _wanted_firmware(getattr(arm_config, joint.value, None)) driver = getattr(arm.motors.get(joint), "_driver", None) if not wanted or not isinstance(driver, (MyActuatorMotor, DamiaoMotor)): continue diff --git a/tests/test_firmware_gains.py b/tests/test_firmware_gains.py index 57f756d1..f260e9c9 100644 --- a/tests/test_firmware_gains.py +++ b/tests/test_firmware_gains.py @@ -237,9 +237,15 @@ async def test_unknown_gain_name_is_a_programming_error(self) -> None: def _arm( - drivers: dict[Joint, object], *, is_left: bool = True, config: object = None + drivers: dict[Joint, object], + *, + is_left: bool = True, + config: object = None, + a4: tuple[Joint, ...] = (), ) -> SimpleNamespace: cfg = AxolConfig() + for j in a4: + getattr(cfg.left if is_left else cfg.right, j.value).wire_mode = "a4" return SimpleNamespace( _is_left=is_left, _arm_config=config or (cfg.left if is_left else cfg.right), @@ -316,12 +322,34 @@ async def test_a_planner_left_on_is_put_back_to_direct_tracking(self) -> None: # written into a running loop until the reset. s1 = _FakeMotor(_stock(), planner=(60000, 10)) with self.assertLogs("almond_axol.robot.axol", level="INFO") as logs: - await apply_firmware_gains(_arm({Joint.SHOULDER_1: s1}), [Joint.SHOULDER_1]) + await apply_firmware_gains( + _arm({Joint.SHOULDER_1: s1}, a4=(Joint.SHOULDER_1,)), [Joint.SHOULDER_1] + ) self.assertEqual(s1.planner, {0: 0, 1: 0}) self.assertEqual(s1.planner_writes, [(0, 0), (1, 0)]) self.assertEqual(s1.resets, 1) self.assertTrue(any("planner_accel 60000 -> 0" in m for m in logs.output)) + async def test_an_impedance_joints_planner_is_left_alone(self) -> None: + # The jelly robot: every impedance replay pinned the right arm's + # planner to 0 (X8 decel floor 10), and tune.breakaway's single-target + # homing then went wild (2026-09-24). On an impedance joint the + # planner shapes nothing the core sends: leave the stock 5000 alone, + # and do not count it against a held joint. + from almond_axol.robot.axol import held_firmware_gain_mismatches + + s1 = _FakeMotor(_stock(), planner=(5000, 5000)) + await apply_firmware_gains(_arm({Joint.SHOULDER_1: s1}), [Joint.SHOULDER_1]) + self.assertEqual(s1.planner, {0: 5000, 1: 5000}) + self.assertEqual(s1.planner_writes, []) + s1.enabled = True + self.assertEqual( + await held_firmware_gain_mismatches( + _arm({Joint.SHOULDER_1: s1}), [Joint.SHOULDER_1] + ), + [], + ) + async def test_a_decel_floor_does_not_block_the_gains(self) -> None: # Right shoulder_1 as found: accel 0 (direct), decel 10 — the X8 # firmware's floor. It is already on direct tracking: the gains must @@ -330,7 +358,7 @@ async def test_a_decel_floor_does_not_block_the_gains(self) -> None: from almond_axol.robot.axol import held_firmware_gain_mismatches s1 = _FakeMotor(_stock(), planner=(0, 10), decel_floor=10) - arm = _arm({Joint.SHOULDER_1: s1}) + arm = _arm({Joint.SHOULDER_1: s1}, a4=(Joint.SHOULDER_1,)) with self.assertLogs("almond_axol.robot.axol", level="INFO") as logs: await apply_firmware_gains(arm, [Joint.SHOULDER_1]) self.assertTrue(any("written to ROM" in m for m in logs.output), logs.output) @@ -343,11 +371,14 @@ async def test_a_decel_floor_does_not_block_the_gains(self) -> None: ) # Coming back from the planner: accel returns to 0, decel stays at 10. s1 = _FakeMotor(_stock(), planner=(60000, 60000), decel_floor=10) - await apply_firmware_gains(_arm({Joint.SHOULDER_1: s1}), [Joint.SHOULDER_1]) + await apply_firmware_gains( + _arm({Joint.SHOULDER_1: s1}, a4=(Joint.SHOULDER_1,)), [Joint.SHOULDER_1] + ) self.assertEqual(s1.planner, {0: 0, 1: 10}) async def test_the_planner_override_reaches_the_motor(self) -> None: cfg = AxolConfig() + cfg.left.shoulder_1.wire_mode = "a4" # the planner is an a4 setting cfg.left.shoulder_1.firmware.planner_accel = 60000.0 # Each joint owns its block: the shoulder_2 and a fresh config keep 0. self.assertEqual(cfg.left.shoulder_2.firmware.planner_accel, 0.0) @@ -466,7 +497,8 @@ async def test_a_held_joint_running_other_gains_is_reported(self) -> None: # Stock gains and the planner a test run left on: not the config set. s1 = _FakeMotor(_stock(), enabled=True, planner=(60000, 60000)) got = await held_firmware_gain_mismatches( - _arm({Joint.SHOULDER_1: s1}, is_left=False), [Joint.SHOULDER_1] + _arm({Joint.SHOULDER_1: s1}, is_left=False, a4=(Joint.SHOULDER_1,)), + [Joint.SHOULDER_1], ) self.assertEqual(len(got), 1) self.assertTrue(got[0].startswith("right.shoulder_1: "), got) From f5b9cf96e3683094fce9202442e61e6ff8cb4e21 Mon Sep 17 00:00:00 2001 From: Shawn Patel Date: Thu, 24 Sep 2026 01:04:56 +0000 Subject: [PATCH 76/80] Every MyActuator joint back on its stock firmware loop in the config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The arms run impedance, where the firmware loops are inert; shoulder_1 and the elbow carried the tuned 0xA4 sets anyway, and every bring-up wrote them back. Both now carry the stock sets (X8: 0.008/0.1/0.03/1e-4, X6: 0.06/0.5/ 0.01/1e-4 — speed_ki read off the jelly robot's untouched left arm); the tuned sets stay defined for an --a4 run to override with. Co-Authored-By: Claude Opus 5.5 (1M context) --- almond_axol/robot/config.py | 15 ++++--- tests/test_firmware_gains.py | 83 +++++++++++++++++------------------- 2 files changed, 48 insertions(+), 50 deletions(-) diff --git a/almond_axol/robot/config.py b/almond_axol/robot/config.py index d31bd66b..1db7d794 100644 --- a/almond_axol/robot/config.py +++ b/almond_axol/robot/config.py @@ -624,9 +624,13 @@ class PositionForceConfig: # Values are the ones recorded at the sweeps (stock X8-P20: position_kp # 0.008, speed_kp 0.03, speed_ki 1e-4, position_kd 0.1; stock X6-P20 roll: # position_kp 0.06, speed_kp 0.01, position_kd 0.5; Damiao wrists: KP_APR -# 54, profiler 2 rad/s²). A stock value never recorded (the X6's speed_ki) is -# left unset, so the motor keeps what it holds. The planner stays -# pinned at 0: a test run's 60000 must not carry over (it reached shoulder_2). +# 54, profiler 2 rad/s²) — every joint's since 2026-09-24, shoulder_1 and +# the elbow included: the arms run impedance, where these loops are inert, +# and the tuned 0xA4 sets (_X8_FIRMWARE_GAINS, _X6_ELBOW_FIRMWARE_GAINS) stay +# defined for an --a4 run to override with. The X6 speed_ki 1e-4 was read off +# the jelly robot's untouched left arm. The planner 0 here applies only to a +# joint actually on wire_mode a4 (see axol._wanted_firmware); an impedance +# joint keeps the motor's stock 5000. _X8_STOCK_FIRMWARE_GAINS = FirmwareGains( position_kp=0.008, position_kd=0.1, @@ -638,6 +642,7 @@ class PositionForceConfig: position_kp=0.06, position_kd=0.5, speed_kp=0.01, + speed_ki=1e-4, planner_accel=0.0, ) _DM_WRIST_STOCK_FIRMWARE_GAINS = FirmwareGains(position_kp=54.0, profile_acc=2.0) @@ -698,7 +703,7 @@ class ArmConfig: # Pose-tracked band-pass centre (kd_host_hz None): the shoulder # mode is the impedance mode, moving with reflected inertia. kd_host=40.0, - firmware=_X8_FIRMWARE_GAINS, + firmware=_X8_STOCK_FIRMWARE_GAINS, ) ) shoulder_2: JointConfig = field( @@ -751,7 +756,7 @@ class ArmConfig: # active at 9.55 Hz. Hardware step/replay A/Bs found that term # increased overshoot without removing a ring; firmware kd=5 # settled the joint without the host-loop phase risk. - firmware=_X6_ELBOW_FIRMWARE_GAINS, + firmware=_X6_ROLL_STOCK_FIRMWARE_GAINS, ) ) wrist_1: JointConfig = field( diff --git a/tests/test_firmware_gains.py b/tests/test_firmware_gains.py index f260e9c9..ec9b9f77 100644 --- a/tests/test_firmware_gains.py +++ b/tests/test_firmware_gains.py @@ -15,7 +15,12 @@ from almond_axol.motor.myactuator import _MA_PID_IDX, MyActuatorMotor from almond_axol.robot import FirmwareGains, JointConfig from almond_axol.robot.axol import apply_firmware_gains -from almond_axol.robot.config import AxolConfig, _calibrated_joint +from almond_axol.robot.config import ( + _X6_ELBOW_FIRMWARE_GAINS, + _X8_FIRMWARE_GAINS, + AxolConfig, + _calibrated_joint, +) _X8 = { "position_kp": 1.0, @@ -27,51 +32,32 @@ class ConfigTest(unittest.TestCase): - def test_shoulder_1_carries_the_tuned_x8_set_shoulder_2_the_stock_one(self) -> None: - # Only shoulder_1 and the elbow run on 0xA4; the impedance joints are - # put back on their motors' factory loops (2026-09-22). + def test_every_myactuator_joint_carries_its_stock_set(self) -> None: + # The arms run impedance, where the firmware loops are inert: every + # joint — shoulder_1 and the elbow included — is kept on its motor's + # factory loop (2026-09-24). The tuned 0xA4 sets stay defined for an + # --a4 run to override with. + x8 = { + "position_kp": 0.008, + "position_kd": 0.1, + "speed_kp": 0.03, + "speed_ki": 1e-4, + "planner_accel": 0.0, + } + x6 = { + "position_kp": 0.06, + "position_kd": 0.5, + "speed_kp": 0.01, + "speed_ki": 1e-4, + "planner_accel": 0.0, + } cfg = AxolConfig() for arm in (cfg.left, cfg.right): - self.assertEqual(arm.shoulder_1.firmware.as_dict(), _X8) - self.assertIsNone(arm.shoulder_1.firmware.position_ki) - self.assertEqual( - arm.shoulder_2.firmware.as_dict(), - { - "position_kp": 0.008, - "position_kd": 0.1, - "speed_kp": 0.03, - "speed_ki": 1e-4, - "planner_accel": 0.0, - }, - ) - - def test_elbow_carries_its_own_set_on_both_arms(self) -> None: - cfg = AxolConfig() - for arm in (cfg.left, cfg.right): - self.assertEqual( - arm.elbow.firmware.as_dict(), - { - "position_kp": 1.4, - "position_kd": 0.1, - "speed_kp": 0.05, - "speed_ki": 1e-5, - "planner_accel": 0.0, - }, - ) - - def test_x6_roll_joints_carry_the_stock_set_on_both_arms(self) -> None: - cfg = AxolConfig() - for arm in (cfg.left, cfg.right): - for joint in (arm.shoulder_3, arm.wrist_1): - self.assertEqual( - joint.firmware.as_dict(), - { - "position_kp": 0.06, - "position_kd": 0.5, - "speed_kp": 0.01, - "planner_accel": 0.0, - }, - ) + for joint in (arm.shoulder_1, arm.shoulder_2): + self.assertEqual(joint.firmware.as_dict(), x8) + for joint in (arm.shoulder_3, arm.elbow, arm.wrist_1): + self.assertEqual(joint.firmware.as_dict(), x6) + self.assertEqual(_X8_FIRMWARE_GAINS.as_dict(), _X8) def test_damiao_wrists_carry_the_stock_profiler_only(self) -> None: cfg = AxolConfig() @@ -87,7 +73,9 @@ def test_the_gripper_has_no_firmware_block(self) -> None: def test_defaults_survive_the_stiffness_blend(self) -> None: cfg = AxolConfig(left_stiffness=0.3).resolved() - self.assertEqual(cfg.left.shoulder_1.firmware.as_dict(), _X8) + self.assertEqual( + cfg.left.shoulder_1.firmware, AxolConfig().left.shoulder_1.firmware + ) def test_calibration_entry_overlays_firmware_block(self) -> None: base = AxolConfig().left.elbow @@ -244,6 +232,11 @@ def _arm( a4: tuple[Joint, ...] = (), ) -> SimpleNamespace: cfg = AxolConfig() + # The apply/held tests exercise writes: give shoulder_1 and the elbow the + # tuned 0xA4 sets an --a4 run would bring (the config default is stock). + for arm in (cfg.left, cfg.right): + arm.shoulder_1.firmware = replace(_X8_FIRMWARE_GAINS) + arm.elbow.firmware = replace(_X6_ELBOW_FIRMWARE_GAINS) for j in a4: getattr(cfg.left if is_left else cfg.right, j.value).wire_mode = "a4" return SimpleNamespace( From 355bc8ca8ba4fd04fbe079509a281b123eea68cb Mon Sep 17 00:00:00 2001 From: Shawn Patel Date: Thu, 24 Sep 2026 01:18:19 +0000 Subject: [PATCH 77/80] Reference motion s1_creep: right shoulder_1 alone at 3 and 6 deg/s MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Synthetic: -20..+40 deg legs at 3 then 6 deg/s with 0.5 s raised-cosine speed blends, every other joint held at slow_osc's start pose (inside the range slow_osc itself sweeps). The single-joint constant-speed test for shoulder_1's notchiness — a ~2 Hz stick-slip on its lightly damped impedance spring — through the production control path. Co-Authored-By: Claude Opus 5.5 (1M context) --- almond_axol/tuning/motions/s1_creep.npz | Bin 0 -> 65726 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 almond_axol/tuning/motions/s1_creep.npz diff --git a/almond_axol/tuning/motions/s1_creep.npz b/almond_axol/tuning/motions/s1_creep.npz new file mode 100644 index 0000000000000000000000000000000000000000..bcaa4301177312a1dad69c127f093a6b26a070b2 GIT binary patch literal 65726 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
Yepq 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

`EVn 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�OToDWiY?JZP#wp*MPD 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 literal 0 HcmV?d00001 From a109db6566e8b79dd230e6918dd9df5bb8a558ef Mon Sep 17 00:00:00 2001 From: Shawn Patel Date: Thu, 24 Sep 2026 16:24:04 +0000 Subject: [PATCH 78/80] Reference motion el_creep: right elbow alone at 3 and 6 deg/s Synthetic: -30..-90 deg legs at 6/3/3/6 deg/s with 0.5 s raised-cosine speed blends, every other joint held at slow_osc's start pose (inside the range slow_osc sweeps). The elbow's counterpart to s1_creep: with shoulder_1 at kp 450 / 480 Hz, the elbow carries as much of slow_osc's 1-3 Hz tip shake as shoulder_1 does. Co-Authored-By: Claude Opus 5.5 (1M context) --- almond_axol/tuning/motions/el_creep.npz | Bin 0 -> 893348 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 almond_axol/tuning/motions/el_creep.npz diff --git a/almond_axol/tuning/motions/el_creep.npz b/almond_axol/tuning/motions/el_creep.npz new file mode 100644 index 0000000000000000000000000000000000000000..8d02e58e2baec9fd6a27d6b91943a67d518a7fd1 GIT binary patch literal 893348 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 p& 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 Date: Thu, 24 Sep 2026 17:56:49 +0000 Subject: [PATCH 79/80] Core: a runaway on the firmware position loop takes the session limp A 0xA4 / 0x73 joint more than 5 deg from the target it was just sent and moving away from it faster than 30 deg/s, on 3 replies in a row, is being driven there by its own loop (right shoulder_1 drove itself into its end stop on the jelly robot, 2026-09-23). The core goes limp as for a silent motor, the runaway joint braked at the MIT maximum kd 5 instead of coasting on the limp 0.25. Impedance joints keep no deviation check: on a compliant controller position error is not a safety signal. Co-Authored-By: Claude Opus 5.5 (1M context) --- rust/axol-rt/src/serve.rs | 130 +++++++++++++++++++++++++++++++++++++- 1 file changed, 129 insertions(+), 1 deletion(-) diff --git a/rust/axol-rt/src/serve.rs b/rust/axol-rt/src/serve.rs index 004817ee..76410a16 100644 --- a/rust/axol-rt/src/serve.rs +++ b/rust/axol-rt/src/serve.rs @@ -152,6 +152,14 @@ //! by the Python torque-residual `ContactWatchdog` (limp gravity-comp //! hold, operator resets); a self-disabled motor just stops contributing //! while the rest of the arm keeps working, as in classic mode. +//! - **The firmware position loop is the one exception: a runaway goes +//! limp.** A joint on 0xA4 / 0x73 is stiff by design, so one more than +//! `A4_RUNAWAY_DEV_RAD` from the target it was just sent and moving +//! *away* faster than `A4_RUNAWAY_VEL_RAD_S`, on `A4_RUNAWAY_SAMPLES` +//! replies in a row, is being driven there by its own loop (right +//! shoulder_1 drove itself into its end stop, 2026-09-23). The session goes +//! limp as for a silent motor, with the runaway joint braked at the MIT +//! maximum kd (`A4_RUNAWAY_KD`) instead of coasting on `LIMP_KD`. //! - Every command batch accepts exactly one fresh reply per motor. A missed //! sample suppresses host damping for that tick; bursty loss (4 of the last //! 32 ticks) marks the joint *degraded* — host damping stays off until a @@ -988,6 +996,47 @@ fn a4_speed_cap(cap_track: f64, v_cmd: f64, max_vel: f64) -> f64 { (cap_track * v_cmd.abs().to_degrees()).clamp(A4_CAP_FLOOR_DPS.min(fixed), fixed) } +/// Runaway guard on the firmware position loop (0xA4 / 0x73): position +/// error past which a joint moving *away* from its target is running away. +/// Normal lag is ~1° at the approach speed; right shoulder_1 on a4 with the +/// planner at 0 in ROM drove itself to its end stop (2026-09-23). +const A4_RUNAWAY_DEV_RAD: f64 = 5.0 * std::f64::consts::PI / 180.0; +/// ...at more than this speed away from the target (30°/s). +const A4_RUNAWAY_VEL_RAD_S: f64 = 30.0 * std::f64::consts::PI / 180.0; +/// ...on this many consecutive samples (~6 ms at 480 Hz), so one noisy +/// reply cannot trip it. +const A4_RUNAWAY_SAMPLES: u32 = 3; +/// Firmware damping (Nm·s/rad, the MIT maximum) on the joint that ran away, +/// once the session is limp: a limp joint's `LIMP_KD` would let a heavy +/// shoulder coast on into its stop; this brakes it and still hand-guides. +const A4_RUNAWAY_KD: f64 = 5.0; + +/// Per-joint runaway detector for the firmware position loop. Unlike the +/// deliberately absent deviation abort on impedance joints (see Safety), a +/// firmware-loop joint is stiff by design: moving fast *away* from its own +/// target means the loop is driving it there — a hand pushing it would be +/// fought, not followed — so the core takes it (and the session) limp. +#[derive(Clone, Copy, Debug, Default)] +struct RunawayGuard { + strikes: u32, +} + +impl RunawayGuard { + /// Feed one sample of an a4 joint; true once it has run away. + fn check(&mut self, meas_p: f64, meas_v: f64, target: f64) -> bool { + let err = meas_p - target; + let away = err.abs() > A4_RUNAWAY_DEV_RAD + && meas_v.abs() > A4_RUNAWAY_VEL_RAD_S + && meas_v.signum() == err.signum(); + self.strikes = if away { self.strikes + 1 } else { 0 }; + self.strikes >= A4_RUNAWAY_SAMPLES + } + + fn reset(&mut self) { + self.strikes = 0; + } +} + #[derive(Clone, Copy, Debug, Default)] pub struct JointCmd { pub p_des: f64, @@ -1767,6 +1816,47 @@ mod tests { assert!(!a4_wire(Vendor::Damiao, WireMode::A4, true, 250.0)); } + #[test] + fn runaway_guard_trips_on_sustained_fast_motion_away_from_the_target() { + let d = |deg: f64| deg.to_radians(); + let mut g = RunawayGuard::default(); + // Shoulder_1 on a4, 2026-09-23: 8° past its target and accelerating + // away — trips on the third consecutive sample, not before. + assert!(!g.check(d(8.0), d(60.0), 0.0)); + assert!(!g.check(d(8.5), d(80.0), 0.0)); + assert!(g.check(d(9.0), d(100.0), 0.0)); + // Same in the negative direction. + let mut g = RunawayGuard::default(); + for _ in 0..A4_RUNAWAY_SAMPLES - 1 { + assert!(!g.check(d(-6.0), d(-40.0), 0.0)); + } + assert!(g.check(d(-6.0), d(-40.0), 0.0)); + } + + #[test] + fn runaway_guard_ignores_lag_slow_drift_and_single_samples() { + let d = |deg: f64| deg.to_radians(); + let mut g = RunawayGuard::default(); + for _ in 0..10 { + // Lagging a fast move: far behind but moving *toward* the target. + assert!(!g.check(d(-10.0), d(90.0), 0.0)); + // Past the target but slow (a settling overshoot, a hand's push). + assert!(!g.check(d(7.0), d(10.0), 0.0)); + // Fast away but inside the normal-lag band. + assert!(!g.check(d(2.0), d(90.0), 0.0)); + } + // An isolated bad reply between good ones never accumulates. + for _ in 0..10 { + assert!(!g.check(d(8.0), d(90.0), 0.0)); + assert!(!g.check(d(0.5), d(5.0), 0.0)); + } + // reset() (a tick off the position frame) clears the count. + g.check(d(8.0), d(90.0), 0.0); + g.check(d(8.0), d(90.0), 0.0); + g.reset(); + assert!(!g.check(d(8.0), d(90.0), 0.0)); + } + #[test] fn pv_joints_follow_the_same_hold_rule_on_damiao_only() { use crate::bringup::{Vendor, WireMode}; @@ -3210,6 +3300,12 @@ fn bus_loop( // forward on the echo's speed between reads (`a4_extrapolate`). Seeded // from bring-up's own 0x92 read so the first echo-only tick has a base. let mut a4_anchor: [Option<(f64, Instant)>; N_SLOTS] = [None; N_SLOTS]; + // 0xA4 joints: the target this tick's position frame carried (None on + // a tick it went out as MIT), the runaway guard checking replies + // against it, and which joint (if any) tripped it. + let mut a4_sent: [Option; N_SLOTS] = [None; N_SLOTS]; + let mut runaway_guard = [RunawayGuard::default(); N_SLOTS]; + let mut ran_away = [false; N_SLOTS]; for m in motors.iter() { if m.vendor == Vendor::MyActuator && m.wire == WireMode::A4 { a4_anchor[m.slot] = Some((m.hold_pos, Instant::now())); @@ -3629,7 +3725,11 @@ fn bus_loop( JointCmd { mode: 0.0, kp: 0.0, - kd: LIMP_KD, + kd: if ran_away[m.slot] { + A4_RUNAWAY_KD + } else { + LIMP_KD + }, kd_host: 0.0, j_eff: 0.0, ..play[m.slot] @@ -3638,6 +3738,7 @@ fn bus_loop( play[m.slot] }; let c = &c; + a4_sent[m.slot] = None; let (arb, frame) = if m.gripper { // Idle until the first target (classic mode leaves the // gripper uncommanded until motion_control too). Slot @@ -3880,6 +3981,7 @@ fn bus_loop( // 0x92 read below restores fine position to the host. a4_follow[motor_index] = true; let target = a4_target(p_cmd, v_trk, m.lead_s); + a4_sent[m.slot] = Some(target); let cap = a4_speed_cap(m.cap_track, v_trk, m.max_vel); ( proto::MA_REQ + m.id as u16, @@ -4153,6 +4255,32 @@ fn bus_loop( // watchdog's job (limp gravity-comp hold, as in classic // mode); a self-disabled motor simply stops contributing, // as it did in classic mode. + // + // The firmware position loop is the exception: stiff by + // design, so a joint on it moving fast *away* from the + // target it was just sent is being driven there by its own + // loop (`RunawayGuard`). Limp takes every joint off the + // position frame; the runaway joint gets the MIT maximum kd + // to brake instead of coasting into its stop. + let slot = motors[idx].slot; + match a4_sent[slot] { + Some(target) => { + if runaway_guard[slot].check(pos, vel, target) && !ran_away[slot] { + ran_away[slot] = true; + go_limp( + limp, + out_tx, + &format!( + "{iface}: {} ran away on the firmware position loop ({:+.1}° from its target, moving away at {:+.0}°/s) — going limp, that joint braked at kd {A4_RUNAWAY_KD}", + motors[idx].joint, + (pos - target).to_degrees(), + vel.to_degrees(), + ), + ); + } + } + None => runaway_guard[slot].reset(), + } } // Replies still outstanding at the window's end. Counted into the From 9eb7cd97e2ce1b1355f3673ead033664fc513fb5 Mon Sep 17 00:00:00 2001 From: Shawn Patel Date: Thu, 24 Sep 2026 19:12:35 +0000 Subject: [PATCH 80/80] Reference motion hold, and tune.motion scores a pass with no motion hold: 40 s at s1_creep's (slow_osc's) start pose - the wrist IMU's floor under the running controller, to split the shake a motion excites from what the controller sustains at rest (the camera reads 0.13 mm vertical with the arm off; s1_creep's ~0.6 mm is mostly a 1-1.75 Hz sway the joint encoders do not see). A pass where no joint moves 1 deg is no longer dropped: it keeps the per-joint buzz columns and the IMU score, and leaves the tracking summary keys out. Co-Authored-By: Claude Opus 5.5 (1M context) --- almond_axol/cli/tune/motion.py | 27 +++++++++++++++++---------- almond_axol/tuning/motions/hold.npz | Bin 0 -> 538888 bytes 2 files changed, 17 insertions(+), 10 deletions(-) create mode 100644 almond_axol/tuning/motions/hold.npz diff --git a/almond_axol/cli/tune/motion.py b/almond_axol/cli/tune/motion.py index c503cc5b..0486b48e 100644 --- a/almond_axol/cli/tune/motion.py +++ b/almond_axol/cli/tune/motion.py @@ -1167,22 +1167,28 @@ def score_pass(a: int, b: int, tag: str) -> dict[str, Any] | None: for key in _TRACKING_KEYS: m[key] = math.nan per_joint[name] = m - if not moved: - print(f"{tag}No joint moved more than 1° — nothing to score.") - return None - if tag: print(f"\n{tag.strip()}") _print_metrics_table(per_joint) - worst = max(moved.items(), key=lambda kv: kv[1]["rms_err"]) - summary = { + summary: dict[str, Any] = { "per_joint": per_joint, - "worst_joint": worst[0], - "mean_rms_err": float(np.mean([m["rms_err"] for m in moved.values()])), - "mean_jitter": float(np.mean([m["err_band_mid"] for m in moved.values()])), "completed": bool(b - a >= len(sent)), } + if moved: + worst = max(moved.items(), key=lambda kv: kv[1]["rms_err"]) + summary["worst_joint"] = worst[0] + summary["mean_rms_err"] = float( + np.mean([m["rms_err"] for m in moved.values()]) + ) + summary["mean_jitter"] = float( + np.mean([m["err_band_mid"] for m in moved.values()]) + ) + else: + # A hold (the ``hold`` motion): no tracking to score, but the + # buzz columns and the wrist IMU's floor under the running + # controller are the point of it. + print(f"{tag}No joint moved more than 1° — hold: buzz and IMU only.") # The pass on the wrist IMUs' clock: its log origin is the execute() # start, so the IMU series shares the run's time axis. origin = log_abs[a] - log_t[a] @@ -1254,7 +1260,8 @@ def score_pass(a: int, b: int, tag: str) -> dict[str, Any] | None: print( f" [{k + 1}] {math.degrees(m.get('buzz', math.nan)):.3f}° on {name} " f"@ {m.get('buzz_hz', math.nan):.0f} Hz / " - f"{math.degrees(sm['mean_jitter']):.3f}° / {sm['worst_joint']}" + f"{math.degrees(sm.get('mean_jitter', math.nan)):.3f}° / " + f"{sm.get('worst_joint', 'hold')}" + ("" if sm["completed"] else " (cut short)") ) diff --git a/almond_axol/tuning/motions/hold.npz b/almond_axol/tuning/motions/hold.npz new file mode 100644 index 0000000000000000000000000000000000000000..2ce4d8020df87fa457cea71b711db0e50b8bbc44 GIT binary patch literal 538888 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 YY_q6usm|i?8`<1Y{CyYmkLR-NpWXA!A^-pY literal 0 HcmV?d00001