> ## Documentation Index
> Fetch the complete documentation index at: https://docs.almond.bot/llms.txt
> Use this file to discover all available pages before exploring further.

# almond_axol.robot

> Hardware controller (Axol), simulator (Sim), configuration, and gravity compensation.

Hardware controller for both arms. `Axol` opens one SocketCAN bus per arm on entry, enables all 16 motors, and calibrates the gripper open-stop. `Sim` is a drop-in replacement that renders the robot in a browser using viser (requires the `sim` extra).

```python theme={null}
from almond_axol.robot import Axol, AxolConfig, ArmConfig, JointConfig, FrictionParams, Sim
```

## `Axol`

```python theme={null}
Axol(
    config: AxolConfig = AxolConfig(),
    left_channel: str | None = "can_alm_axol_l",
    right_channel: str | None = "can_alm_axol_r",
)
```

Pass `left_channel=None` or `right_channel=None` to operate a single arm. Both arms are brought up concurrently on `__aenter__`.

```python theme={null}
import asyncio
import numpy as np
from almond_axol.robot import Axol

async def main():
    async with Axol() as axol:
        await axol.start_telemetry(500)   # 500 Hz background polling

        # non-blocking cached reads (after telemetry warms up)
        print("left positions (rad):", axol.left.positions)
        print("left torques (Nm):", axol.left.torques)

        # primary control: impedance for arm joints, position-force for gripper
        q = np.zeros(8, dtype=np.float32)
        q[7] = 1.0  # open gripper
        await axol.motion_control(left=q, right=q)

asyncio.run(main())
```

### Lifecycle methods

| Method                              | Description                                                                                                                                                                                                                                                                                                                              |
| ----------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `connect()`                         | Open the CAN buses only — nothing is actuated; all read APIs (`get_holding()`, `get_positions()`, ...) become usable for inspecting a robot of unknown state                                                                                                                                                                             |
| `enable(hold=True)`                 | Bring every motor up. Idempotent per motor: joints already holding keep holding (reads only, gripper keeps its grasp); cold joints get the full bring-up incl. gripper calibration. With `hold=True` (default) the robot finishes actively holding its measured pose — see [Reconnecting to a live robot](#reconnecting-to-a-live-robot) |
| `disable()`                         | Disable all motors and close CAN buses                                                                                                                                                                                                                                                                                                   |
| `disconnect()`                      | Close CAN buses leaving motor torque exactly as it is                                                                                                                                                                                                                                                                                    |
| `start_telemetry(hz, torque=False)` | Begin background polling loop on all motors                                                                                                                                                                                                                                                                                              |
| `wait_for_telemetry(timeout=5.0)`   | Block until every motor has reported a position; call after `start_telemetry` before the first cached `positions` read                                                                                                                                                                                                                   |
| `stop_telemetry()`                  | Stop background polling                                                                                                                                                                                                                                                                                                                  |
| `clear_errors()`                    | Clear latched error flags on all motors                                                                                                                                                                                                                                                                                                  |
| `set_control_mode(mode)`            | Set `ControlMode` on all motors. **MyActuator motors reboot to switch modes (torque off \~2 s)** — never call it while the arms hold a load; use `enable(hold=False)` in flows that manage modes themselves (Damiao takes a live mode flip in stride — it is a plain register write)                                                     |

### Reconnecting to a live robot

If a controlling process dies (or disconnects) while the robot is torqued on, the motors keep holding their last commanded pose. Startup code does not need to know whether that happened: `enable()` queries each motor and converges —

* joints that are **already holding** are attached to with reads only (never reset — a reset reboots MyActuator motors and drops the arm for \~2 s), and a holding gripper keeps its grasp: its open-stop calibration is restored from the values persisted by the last full bring-up instead of re-running the sweep that forces the jaws open;
* **cold** joints get the classic full bring-up;
* a mixed robot (e.g. the previous session died mid-`enable`) simply gets the cold joints brought up while the holding ones are left alone;
* with `hold=True` (the default) `enable()` finishes by commanding the measured pose once (configured gains + gravity feedforward — the arm is already there, so nothing moves), so "enabled" always means *actively holding*.

```python theme={null}
import asyncio
from almond_axol.robot import Axol

async def main():
    axol = Axol()
    await axol.connect()                   # open buses; nothing actuated
    held_l, held_r = await axol.get_holding()   # optional: inspect first
    await axol.enable()                    # holding joints kept holding

    await axol.start_telemetry(500)
    await axol.wait_for_telemetry()

    # Resume from the *measured* pose — after a reconnect the arms are
    # wherever the previous session left them, and the max_step_rad safety
    # check is seeded against that pose, so a stale or default target is
    # rejected instead of yanking the arms.
    left = axol.left.positions
    await axol.motion_control(left=left)
    # ... ramp toward your desired target from here ...

    await axol.disconnect()                # exit, leaving torque as-is

asyncio.run(main())
```

Notes:

* `connect()` is optional — `enable()` opens the buses itself. Call it when you want to inspect state (`get_holding()`, positions, temperatures) before acting. A process that must never actuate can `connect()`, check `get_holding()`, and only proceed when every joint is already holding.
* Custom control modes: pass `enable(hold=False)` to leave freshly brought-up joints enabled but limp, then pick your mode with `set_control_mode()` (position/velocity/force). The reconnect machinery targets the impedance workflow — a session that died in another control mode is asked to `disable()` and re-enable rather than being reattached.
* To force a fresh bring-up of a live robot (re-run gripper calibration, reset motors), call `disable()` first, then `enable()`.
* One corner intentionally raises `MotorError`: a gripper that is holding but has no valid persisted calibration — re-measuring would sweep the jaws open and drop whatever it grips. Empty the gripper, then `disable()` and `enable()`.
* `enable()` also refuses (raising `MotorError`) if any arm joint's encoder reading is implausible for a zero at its calibration end stop — the zero was never set, or is stale, and bringing the robot up on garbage joint-frame values is unsafe. The gate runs **before anything is actuated** (`connect()` and the read APIs still work, for inspecting such a robot); run `axol motor.set-zero-pos --guided` to (re)zero. See [`motor.set-zero-pos`](/cli/motor-set-zero-pos).
* `disconnect()` closes the buses without touching torque, so the robot keeps holding and a later process can reconnect. Use `disable()` only when you actually want to torque off (with the arms in a safe pose).

### State reads

Each returns `(left_array, right_array)` where the absent arm is `None`.

| Method               | Units                                                                                    |
| -------------------- | ---------------------------------------------------------------------------------------- |
| `get_positions()`    | rad (gripper: `[0, 1]`)                                                                  |
| `get_velocities()`   | rad/s                                                                                    |
| `get_torques()`      | Nm (Damiao) / A (MyActuator)                                                             |
| `get_temperatures()` | °C                                                                                       |
| `get_voltages()`     | V                                                                                        |
| `get_error_codes()`  | `list[MotorStatus]`                                                                      |
| `get_holding()`      | `list[bool]` — enabled-and-holding per motor (read-only; usable right after `connect()`) |
| `get_gains()`        | `list[MotorGains]`                                                                       |

### State writes

| Method                                           | Description                                                                                              |
| ------------------------------------------------ | -------------------------------------------------------------------------------------------------------- |
| `motion_control(left, right)`                    | Impedance (arm) + position-force (gripper); primary control method                                       |
| `set_positions_velocity(left, right, max_speed)` | Motor built-in position controller                                                                       |
| `set_velocity(left, right)`                      | Motor built-in speed controller                                                                          |
| `set_gains(left, right)`                         | Write PID gains; persisted to flash                                                                      |
| `set_zero_position(left, right)`                 | Save current shaft position as encoder zero (calibrated at a mechanical end stop, not the rest position) |
| `set_acceleration(left, right)`                  | Set per-joint acceleration ramp (rad/s²)                                                                 |

Individual arms are accessible via `axol.left` and `axol.right` (`AxolArm`), which expose the same methods operating on a single arm.

## `Sim`

`Sim` implements the same interface as `Axol`. Use it to visualise motion without hardware. Requires the `sim` extra.

```python theme={null}
import asyncio
import numpy as np
from almond_axol.robot import Sim

async def main():
    async with Sim(port=8002) as sim:
        q = np.zeros(8, dtype=np.float32)
        await sim.motion_control(left=q)
        await asyncio.sleep(float("inf"))  # keep the viser server alive

asyncio.run(main())
```

Open `http://localhost:8002` in a browser to view the robot.

<Note>
  `Sim` renders whatever *you* command with `motion_control`. Running [`axol teleop --sim`](/cli/teleop) instead wraps a `Sim` in the full teleop stack — with no headset connected the arms just hold the rest pose. To drive them without a headset (e.g. in a test), stream [`VRFrame`](/api/vr) messages to the teleop VR WebSocket at `wss://localhost:8000/ws` (self-signed cert — disable TLS verification) with both `l_lock` and `r_lock` set `true` to engage tracking.
</Note>

## Configuration — `AxolConfig`, `ArmConfig`, `JointConfig`

Each arm joint is configured with a single `JointConfig` carrying its impedance gains, friction-comp model, and the inertial of the body it drives:

```python theme={null}
from almond_axol.robot import Axol, AxolConfig, FrictionParams

config = AxolConfig()
config.left.elbow.kp = 200
config.left.elbow.mass = 0.6
config.left.elbow.com = (-0.025, 0.0, -0.07)
config.left.elbow.friction = FrictionParams(fc=0.4, k=10.0, fv=0.05, fo=0.0)
async with Axol(config=config) as axol: ...
```

Or build a fully custom arm with `dataclasses.replace` (start from the `AxolConfig` defaults so you keep the per-side friction values that get injected at construction):

```python theme={null}
from dataclasses import replace
from almond_axol.robot import AxolConfig, JointConfig, FrictionParams

left = replace(
    AxolConfig().left,
    shoulder_1=JointConfig(
        kp=35.0, kd=1.2,
        friction=FrictionParams(fc=0.0, k=0.0, fv=0.0, fo=0.0),
        mass=2.0, com=(0.065, 0.0, 0.0),
    ),
)
```

### `JointConfig` fields

| Field      | Type                         | Description                                                                                                                                                                                                                                       |
| ---------- | ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `kp`       | `float` (`[0, 500]`)         | Impedance position stiffness                                                                                                                                                                                                                      |
| `kd`       | `float`                      | Impedance velocity damping. Clamped by the motor to its firmware's range — `5` on Damiao and legacy MyActuator, up to `50` on newer (V4.4+) MyActuator firmware (auto-detected on `enable()`); use `kd_soft` to add damping beyond a motor's cap. |
| `friction` | `FrictionParams`             | `fc`, `k`, `fv`, `fo` — friction-comp model `fc·tanh(0.1·k·v) + fv·v + fo` (see `compute_friction` in `robot/control.py`)                                                                                                                         |
| `mass`     | `float`                      | Mass of the URDF body this joint drives (kg). For `wrist_3` this includes the gripper.                                                                                                                                                            |
| `com`      | `tuple[float, float, float]` | Centre-of-mass of the same body in its URDF link frame (m). Used by gravity comp.                                                                                                                                                                 |
| `j_eff`    | `float` (default `0.0`)      | Effective scalar inertia (kg·m²) for acceleration feedforward `τ = j_eff · q̈_des`.                                                                                                                                                               |
| `kd_soft`  | `float` (default `0.0`)      | Extra software velocity damping (Nm·s/rad) applied as `τ = kd_soft · (v_des − v_meas)`. Equivalent to raising `kd` past the motor's firmware cap (e.g. past 5 on Damiao or legacy MyActuator firmware).                                           |

Gravity feedforward is computed centrally from the URDF — see [Gravity compensation](#gravity-compensation) — and uses the per-joint `mass` and `com` directly.

`ArmConfig.gripper` is a `PositionForceConfig` with `torque_limit` (Nm) and `max_speed` (rad/s); the gripper's mass is already lumped into `wrist_3.mass` (the gripper joint is fixed).

`AxolConfig` also exposes top-level parameters:

| Field             | Default | Description                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  |
| ----------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `has_gripper`     | `True`  | Whether this robot is the gripper-equipped SKU. Set `False` for the gripperless SKU: the gripper motors are never constructed, enabled, or calibrated; the gripper element of every `(8,)` joint array is ignored on write and reported as `0.0` on read (array shapes are unchanged). LeRobot datasets recorded on a gripperless robot carry 7 channels per arm instead of 8.                                                                                                                                                                                                                                                                                                                                                                                                                                                                               |
| `max_step_rad`    | `0.5`   | Maximum allowed change in any arm joint (rad) between consecutive `motion_control` calls. Commands that exceed this are dropped and a warning is logged. Set to `float("inf")` to disable. At 30 Hz, 0.5 rad/step ≈ 15 rad/s — roughly 2.5× the teleop velocity ceiling.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                     |
| `left_stiffness`  | `0.5`   | Compliance ↔ stiffness blend for the **left** arm. Either a scalar in `[0, 1]` (applied to every joint) or a 7-tuple of per-joint factors (order: `shoulder_1`, `shoulder_2`, `shoulder_3`, `elbow`, `wrist_1`, `wrist_2`, `wrist_3` — gripper excluded). `0` keeps the per-joint compliant gains; `1` restores the pre-tuning industrial gains in `_STIFF_GAINS` (e.g. `shoulder_1` → `kp=500`); the default `0.5` is the geometric mean (e.g. `shoulder_1` `kp` ≈ 141). `kp` / `kd` interpolate geometrically (log-space — matches perceived stiffness); `j_eff` / `kd_soft` scale linearly to 0 at `s=1`. The blend is baked into the `left` / `right` gains by `AxolConfig.resolved()`, called once at the robot-construction boundary (`Axol.__init__`); the stiffness fields themselves are left untouched so a serialized config round-trips cleanly. |
| `right_stiffness` | `0.5`   | Same, for the **right** arm.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                 |

```python theme={null}
config = AxolConfig(left_stiffness=1.0, right_stiffness=1.0)   # both arms, stiff industrial feel
config = AxolConfig(left_stiffness=0.5, right_stiffness=0.5)   # geometric mean: shoulder_1 kp ≈ 141
config = AxolConfig(                                           # per-joint, left only
    left_stiffness=[0.8, 0.8, 0.5, 0.5, 0.2, 0.2, 0.0],
)
```

Both arms share the same `ArmConfig` defaults for gains and masses; the right arm gets CoMs mirrored across X via `ArmConfig.mirror_to_right()`. Per-motor friction values are identified separately for each arm (left/right motors measurably differ) — see `_LEFT_FRICTION` / `_RIGHT_FRICTION` in `almond_axol/robot/config.py`. Pass an explicit `left=` / `right=` to override either side.

## Gravity compensation

`almond_axol.robot.gravity.GravityCompensator` builds a MuJoCo model from the bundled URDF and computes per-joint gravity torques as `qfrc_bias` with `qvel=0` (Coriolis terms vanish). Because the URDF is the full kinematic chain, each parent joint's gravity load includes the contribution of every child link — this is the main improvement over the previous per-joint `ga·cos(q) + gb·sin(q)` model, which silently ignored child-link mass.

Per-link masses are not taken from the bundled URDF — the Onshape exporter leaves placeholder sub-gram values that produce essentially zero gravity. Real per-link mass and CoM live on each `JointConfig.mass` / `JointConfig.com` in `almond_axol/robot/config.py` (CoMs come from the CAD inertial origins; masses are tuned in place against measured joint torques and are typically lower than the CAD values, since Onshape often over-assigns aluminum-class densities to parts that are hollow / 3D-printed).

If the arms sag or push back in gravity-comp mode, tune the relevant joint's `mass` and `com` on `AxolConfig` and pass it to `Axol`:

```python theme={null}
from almond_axol.robot import AxolConfig, Axol

config = AxolConfig()
config.left.elbow.mass = 0.6
config.left.elbow.com = (-0.025, 0.0, -0.07)
async with Axol(config=config) as axol: ...
```

See the [`gravity-comp`](/cli/gravity-comp) CLI command to hold the arms in gravity-compensation mode interactively.
