Workflows

Real Hardware

Connect the AR4 arm via USB before running these commands.

# Start hardware driver (calibrates automatically) + MoveIt
docker compose --profile hardware up

Calibration runs on startup. The arm steps through each joint and finds the limit switches. Keep clear of the arm during this.

To skip calibration if the arm is already homed:

CALIBRATE=False docker compose --profile hardware up

Record on real hardware

# Hardware must already be running
TASK="pick the teal cube" docker compose run --rm record

Inference on real hardware

# Hardware must already be running — auto-selects the latest checkpoint
USE_SIM_TIME=false docker compose run --rm smolvla
 
# Or pin a specific checkpoint
USE_SIM_TIME=false \
SMOLVLA_CHECKPOINT=/data/checkpoints/smolvla_ar4/checkpoints/040000/pretrained_model \
  docker compose run --rm smolvla

Replay a recorded episode

Play a recorded LeRobot episode back on the arm — a policy-free sim-to-real sanity check. It reads the joint trajectory straight from the dataset parquet and streams it to the driver, so it needs no model and no GPU. Run it to confirm a dataset's actions actually reproduce on the real arm before trusting a policy trained on it.

# Hardware must already be running. Plays the default dataset + episode.
docker compose run --rm replay

Every field is an environment variable you can override on the command line:

VariableDefaultMeaning
REPLAY_EPISODE0Which episode to play
REPLAY_DATASET…/ar4_pick_place_furinno_v10Dataset directory under /data — a fallback, not a recommendation; see Bring your own dataset
HZ0Playback rate; 0 uses the dataset's recorded fps
USE_SIM_TIMEfalsefalse = wall clock (real hardware); true = Gazebo /clock
REPLAY_GRIPPER_CLOSED_NORM-1-1 auto-rescales a sim grasp to a full physical close; 0 disables; or an explicit floor in [0, 1]
# Pick an episode
REPLAY_EPISODE=3 docker compose run --rm replay
 
# Replay a different dataset
REPLAY_DATASET=/data/datasets/ar4_pick_place_moveit docker compose run --rm replay
 
# Replay against the simulation instead of hardware
USE_SIM_TIME=true docker compose run --rm replay

Bring your own dataset. Any LeRobot v3.0 dataset replays. The node reads only meta/info.json (for fps; it falls back to 15 if the file is absent) and the action, episode_index and frame_index columns of data/**/*.parquetit never opens the videos, so a replay-only copy is a few MB of parquet rather than the whole dataset. Nothing about the cameras, task strings, or policy has to match.

Put the dataset directory anywhere under data/ in the repo — it is mounted read-only at /data — and point REPLAY_DATASET at its path inside the container. How it got there is up to you: recorded locally, pulled from whatever object storage you use, huggingface-cli download, or plain scp.

The default above only applies when REPLAY_DATASET is unset, so rather than editing docker-compose.yaml every time you switch datasets, pin yours in a .env file at the repo root — Docker Compose reads it automatically and it is gitignored, so it stays local to your machine and never lands in a commit:

# .env  (untracked)
REPLAY_DATASET=/data/datasets/my_dataset
REPLAY_EPISODE=0

Gripper rescale. Datasets recorded before AURA-811 never command a full close: their grasp value floors around 0.643 because the recorder asked for a gap matched to the object instead of driving the jaws shut, so on real hardware the jaws stop 64% open and never reach the object. The default REPLAY_GRIPPER_CLOSED_NORM=-1 auto-detects that floor and rescales the recorded [floor, 1.0] range onto [0, 1.0]. Datasets recorded or relabelled afterwards already carry 0.0 for a grasp, and the auto-detect leaves them alone (it logs that the episode already commands full closes), so the flag is there for legacy artifacts and can be set to 0 once none remain in use.

Gripper: current-limited grasp

The servo gripper closes with a current-limited grasp rather than driving to a fixed fully-closed position. It creeps the jaws closed and freezes the commanded position the moment the measured current crosses a contact threshold, holding a gentle clamp wherever it meets the object. This:

  • grasps objects of any size — it stops at contact, not a preset width, and
  • avoids stalling the servo. A hard stall on a coreless servo can collapse a marginal power rail (a brownout), which makes the jaws close-then-release in a retry loop instead of gripping. Staying at a light contact current keeps the rail steady.

Servo travel is set per gripper in annin_ar4_driver/config/gripper_driver.yaml (closed_servo_angle / open_servo_angle), and the grasp policy is toggled with use_overcurrent_protection.

Not yet calibrated: servo angle against jaw opening

The driver converts between the URDF's prismatic jaw range and the servo by assuming the two are proportional. linear_pos_to_servo_angle maps [0, 0.014] m linearly onto [closed_servo_angle, open_servo_angle], currently 0 and 55 degrees. Nobody has measured whether 55 degrees actually places each jaw 14 mm off centre, or whether the relationship holds across the sweep, so the conversion is an assumption inherited from the URDF rather than a property of this gripper. The servo was swapped for an HPS-3518SG and re-tuned on 2026-07-24, which moved both endpoints, and the assumption was carried over untouched.

Commanded actions survive this, because the only two values a grasp uses are endpoints: 0.0 means drive closed until the current limiter freezes at contact, and 1.0 means open. The observation does not survive it. observation.state[6] is the measured opening divided by 0.014, which in Gazebo is a true jaw displacement and on the real arm is a fraction of a servo sweep. A policy trained in simulation sees 0.716 while holding the 23 mm cube; the real arm will report whatever fraction of its sweep contact happened at, and we currently have no way to say whether those two numbers describe the same physical opening. That is a silent shift on the one input that tells the policy whether it is holding anything.

Closing it needs a bench session with digital calipers, not a code change:

  1. Set use_overcurrent_protection: false so the grasp policy does not freeze the command mid-sweep, and run the driver with nothing in the jaws.
  2. Command normalized openings from 0.0 to 1.0 in ten steps (the driver multiplies by 0.014 m before converting to an angle) and measure the gap between the jaw pad faces at each step.
  3. Sweep in both directions to capture backlash, and repeat the pair three times so the spread is visible rather than assumed.
  4. Record the two endpoints explicitly: the residual gap at 0 degrees, which the config notes is a small opening rather than a true closure, and the maximum gap at 55 degrees.
  5. Re-enable the grasp policy and close on three objects of known width, including the 23 mm cube and the 24 mm cylinder, logging the servo angle at which each freeze occurs. Comparing the fitted gap at that angle against the object gives the contact threshold in millimetres.

The deliverable is a table of commanded angle against measured gap, a fit with its residuals, and those two endpoint figures. If the fit is linear and the full opening differs from the 28 mm the URDF implies, either the jaw limit is corrected for the real arm or the scale is recorded in the dataset metadata so the gripper channel means the same thing in both places. If it is not linear, the driver's proportional conversion has to be replaced by the measured curve. Tracked in AURA-811.


On this page