From 55b398e313c2727edd495fcacc3f520e1adbb52c Mon Sep 17 00:00:00 2001 From: Michael Johnson Date: Wed, 5 Aug 2026 11:37:59 +0100 Subject: [PATCH 01/22] Virtual-leader teleop and LeRobot episode recording for the SO-101 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Teleoperation with no leader arm, and the recordings that make it worth doing. Design and workflow in mote_arm/TELEOP.md. The leader is a pose held in software, moved by the keyboard (virtual_leader), published on leader/joint_states, and turned into arm_controller trajectories by arm_mirror — through mote_arm.control, like every other command client, so the arm keeps exactly one command path. Of the three candidate shapes this is the second: LeRobot's own keyboard teleop would have been cheapest, but it means running LeRobot's robot class and bus driver on the Pi, which is the thing the bring-up decision exists to avoid. LeRobot is where the dataset goes, not where the arm is driven from — and that split is the design. IK jog was out for v1 and stays out. The frontend is deliberately the replaceable part: the mirror's whole contract is leader/joint_states plus a latched teleop/estop, so a slider GUI or a gamepad is a drop-in with no code here. Every safety rule is decided in teleop.py and nowhere else, so all of it is unit-tested without a bus: soft-limit clamping, a 0.5 rad/s rate limit (a leader that jumps becomes a ramp, never a lunge), the deadman, the panic latch, and re-seeding from the measured pose on every resume so a pause cannot bank up motion and pay it out later. The deadman is the leader's *liveness*: a frontend publishes only while it is being driven, so a released key, a closed window and a dropped SSH session all arrive as the same thing. On that transition the mirror issues one goal at the arm's present position — stopping it there rather than letting it coast to the setpoint it was still travelling towards — and then sends nothing, because an absent goal is a hold. Panic is controller deactivation, since torque *is* controller activation now. One structural consequence: the mirror ticks on its own thread rather than on a ROS timer. Taking hold of the arm is a switch_controller call, and a service call made from inside an executor callback can never complete — the future is resolved by the executor the callback is blocking. arm-jog avoids this by driving from its REPL thread; the mirror does the same with a plain loop while cli.spin_background spins the node. Episodes: episode_record samples joint_states (observation), arm_controller/joint_trajectory (action) and /image_raw/compressed at 20 Hz. The action is the mirror's output, not the leader's pose, because a policy replaces whatever produces goals — and it is read off the trajectory topic rather than from the mirror, so an arm-jog session records just as well. It writes a *capture* under $MOTE_HOME/episodes — JSON lines plus the compressed frames stored byte-for-byte, standard library only, since the Pi carries no parquet or ffmpeg and should not have to. tools/lerobot_export.py converts a capture to a real LeRobotDataset off-board, in its own linux-64 pixi env, through LeRobot's own API rather than emitting the files: the format already moved once (v2.1 -> v3.0) and a hand-rolled writer would be wrong the next time it moved. It resamples onto the exact 1/fps grid first — LeRobot derives timestamps from the frame index, so a slipped capture would otherwise export as if its timing had been perfect — and loads the result back to verify. episode_replay reads the capture, not the dataset, so replay needs nothing off-board; it approaches the first pose, replays at a quarter speed, and stops on sustained lag. mock_arm presents the control stack's exact surface — the trajectory topic and switch_controller — with nothing behind it, plus a synthetic camera encoded with zlib and struct, and starts limp as the real stack does. So the whole loop runs on a workstation: pixi run arm-teleop-test drives leader -> mirror -> arm_controller -> arm -> record -> replay -> export plan headless and is the gate before the bench. pixi run arm-bench-teleop is the guided hardware session (BENCH.md step 8); it prompts for the three observations no script can make and writes a report. Verified against the mock control stack: 220 frames over 10.9s with no dropped ticks, replay finishing within 0.0000 rad of the last action at 0.010 rad of steady lag, and an exported v3.0 dataset (aggregated parquet + MP4 shards) that loads back through LeRobot with the right shapes and reads in LeRobot's own lerobot-dataset-viz. The state-only path forced by the camera/arm clash (GitHub #2) exports and verifies the same way. Not verified: anything on the arm itself — that is BENCH.md step 8, and it needs a human. Two things found by running it. Two publishers on one arm fight, which the stall guard caught before the loop script learned to stop the leader first. And mote_arm keeps its own reading of MOTE_HOME rather than importing mote_bringup's: the dependency runs mote_bringup -> mote_arm (the base launch resolves this arm's calibration into the URDF) and colcon cannot order the packages if it runs back — episodes_root therefore goes through mote_arm.poses.mote_home, as arm_gains and calibrate already do. Incidental: the lag-supervision rule moves to motion.py, shared with arm-pose go rather than reimplemented. TELEOP.md is mounted into the docs site (#93) beside the arm's README and bench runbook, so the three cross-link as pages rather than as repo paths. 1005 tests pass, lint clean, `mkdocs build --strict` clean. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_017hfWyNtkVh16hyvCK8K9cd --- CLAUDE.md | 56 + docs/hooks/repo_links.py | 1 + mkdocs.yml | 1 + mote_arm/BENCH.md | 35 + mote_arm/README.md | 8 + mote_arm/TELEOP.md | 292 ++ mote_arm/mote_arm/arm_pose.py | 26 +- mote_arm/mote_arm/episode.py | 346 ++ mote_arm/mote_arm/episode_record.py | 293 ++ mote_arm/mote_arm/episode_replay.py | 270 ++ mote_arm/mote_arm/mirror.py | 155 + mote_arm/mote_arm/mock_arm.py | 228 ++ mote_arm/mote_arm/motion.py | 66 + mote_arm/mote_arm/teleop.py | 174 + mote_arm/mote_arm/virtual_leader.py | 315 ++ mote_arm/package.xml | 8 +- mote_arm/setup.py | 5 + mote_arm/test/teleop_loop/check_capture.py | 87 + mote_arm/test/teleop_loop/run_teleop_loop.sh | 105 + mote_arm/test/test_episode.py | 145 + mote_arm/test/test_motion.py | 53 + mote_arm/test/test_teleop.py | 157 + mote_arm/test/test_teleop_node.py | 188 ++ mote_arm/tools/bench_teleop.sh | 131 + mote_arm/tools/lerobot_export.py | 279 ++ mote_bringup/launch/arm_launch.py | 24 +- pixi.lock | 3158 ++++++++++++++++++ pixi.toml | 44 + 28 files changed, 6630 insertions(+), 20 deletions(-) create mode 100644 mote_arm/TELEOP.md create mode 100644 mote_arm/mote_arm/episode.py create mode 100644 mote_arm/mote_arm/episode_record.py create mode 100644 mote_arm/mote_arm/episode_replay.py create mode 100644 mote_arm/mote_arm/mirror.py create mode 100644 mote_arm/mote_arm/mock_arm.py create mode 100644 mote_arm/mote_arm/motion.py create mode 100644 mote_arm/mote_arm/teleop.py create mode 100644 mote_arm/mote_arm/virtual_leader.py create mode 100755 mote_arm/test/teleop_loop/check_capture.py create mode 100755 mote_arm/test/teleop_loop/run_teleop_loop.sh create mode 100644 mote_arm/test/test_episode.py create mode 100644 mote_arm/test/test_motion.py create mode 100644 mote_arm/test/test_teleop.py create mode 100644 mote_arm/test/test_teleop_node.py create mode 100755 mote_arm/tools/bench_teleop.sh create mode 100644 mote_arm/tools/lerobot_export.py diff --git a/CLAUDE.md b/CLAUDE.md index 8880ce8..013dd10 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -27,6 +27,13 @@ pixi run arm-jog # Interactive per-joint jog CLI (needs a stack owning th pixi run arm-check # Standalone arm bus enumeration + health (read-only, base stopped) pixi run arm-calibrate # Range calibration: centre the joints, sweep, emit limits pixi run arm-pose # Teach/replay named arm poses; narrow the envelope +pixi run arm-teleop # Virtual-leader teleop: keyboard -> leader pose (mote_arm/TELEOP.md) +pixi run arm-mirror # Mirror: leader pose -> clamped, rate-limited arm_controller goals +pixi run arm-mock # The arm control stack's interface, no hardware (+ --camera) +pixi run arm-record # Record teleop episodes into $MOTE_HOME/episodes +pixi run arm-replay # Replay a recorded episode on the arm, gated +pixi run arm-teleop-test # Headless teleop->record->replay loop vs the mock arm +pixi run arm-bench-teleop # Guided hardware teleop session (needs a human) pixi run sync # rsync project to Pi at SSH host 'mote' pixi run setup # One-time Pi setup: udev + wifi + systemd (needs sudo) pixi run udev # Install udev rules + dialout group (needs sudo) @@ -77,6 +84,12 @@ pixi run test # colcon test for mote_hardware (gtest) pixi run -e fleet fleet-server # fleet API + dashboard on the fleet box pixi run -e dev test-fleet # mote_fleet tests incl. the real-broker e2e run +# LeRobot environment only (`lerobot`: torch + ffmpeg + the HuggingFace stack, no +# ROS; linux-64 only. Off-board, for the same reason `inference` is — the aarch64 +# Pi records episodes but must not carry this.) +pixi run -e lerobot arm-export -- --capture ~/.mote/episodes/ # capture -> LeRobotDataset +pixi run -e lerobot -- lerobot-dataset-viz --repo-id --root --episode-index 0 + # Lint environment only (pre-commit; minimal env, no ROS — auto-selected) pixi run lint # run all pre-commit hooks across the tree (~1 s cached) pixi run lint-install # wire pre-commit into .git/hooks (one time per clone) @@ -849,6 +862,49 @@ section. Contains: control stack stopped (`pixi run kill`). `jog` and `arm-pose` do not. - Torque policy, control interfaces, and calibration in `mote_arm/README.md`; the human bench runbook in `mote_arm/BENCH.md`. +- **Virtual-leader teleop + episode recording** (`mote_arm/TELEOP.md`) — teleop + with **no leader arm**: a leader pose held in software, moved by the keyboard + (`virtual_leader`, `pixi run arm-teleop`), published on `leader/joint_states`, + which `arm_mirror` (`pixi run arm-mirror`, or `pixi run arm mirror:=true`) + turns into `arm_controller` trajectories through `control.py`, like every + other command client. **The frontend is deliberately replaceable** — the + mirror's whole contract is `leader/joint_states` + a latched `teleop/estop`, + so a slider GUI or a gamepad is a drop-in. LeRobot's own teleop was rejected + for the reason the bring-up rejected LeRobot on the robot at all: it would put + torch on the Pi. **Every safety rule lives in `teleop.py`** and nowhere else — + soft-limit clamping, a 0.5 rad/s rate limit (so a leader that *jumps* becomes + a ramp), the deadman (the leader's *liveness* is the deadman: a released key, + a closed window and a dropped SSH session all arrive as "no fresh pose", and + the mirror then issues one goal at the arm's present position so it stops + there rather than coasting on), the latched panic (deactivates + `arm_controller` — torque *is* controller activation — and refuses goals until + cleared), and re-seeding from measured on every resume so a pause cannot bank + up motion. **The mirror ticks on its own thread, not on a ROS timer**: taking + hold of the arm is a `switch_controller` call, and a service call made from + inside an executor callback can never complete, because the future is resolved + by the executor the callback is blocking (`arm-jog` avoids this by driving + from its REPL thread). `mock_arm` (`pixi run arm-mock`) presents that same + ros2_control surface — trajectory topic plus `switch_controller` — with no bus + and an optional pure-zlib synthetic camera, so the whole loop runs on a + workstation: `pixi run arm-teleop-test` drives it headless and is the + pre-bench gate; `pixi run arm-bench-teleop` is the guided hardware session. + **Episodes**: `episode_record` samples `joint_states` (observation), the + `arm_controller/joint_trajectory` topic (action — read off the wire rather + than from the mirror, so an `arm-jog` session records too) and + `/image_raw/compressed` into a **capture** under `$MOTE_HOME/episodes/` — JSON + lines plus the compressed frames stored byte-for-byte, written with the + standard library alone, because the Pi carries no parquet or ffmpeg. + `tools/lerobot_export.py` (`pixi run -e lerobot arm-export`) converts a + capture into a real `LeRobotDataset` **through LeRobot's own API** + (`create`/`add_frame`/`save_episode`/`finalize`, then loads it back to verify) + rather than emitting the files — the format already moved once (v2.1 → v3.0) + and a hand-rolled writer would be wrong the next time. It resamples onto the + exact 1/fps grid first, since LeRobot derives timestamps from the frame index + and would otherwise silently stretch a slipped capture. `episode_replay` + (`pixi run arm-replay`) reads the *capture*, not the dataset, so replay needs + nothing off-board; it approaches the first pose, replays at a quarter speed, + and stops on sustained lag (`motion.py`, shared with `arm-pose go`). Stop the + leader before replaying — two things commanding `arm_controller` fight. - `arm_gains` (`pixi run arm-gains show|apply|sweep`) — the servos' position-loop gains live in EEPROM, i.e. invisible config a servo swap would silently revert, so `robot.yaml`'s `arm.gains` is the source of truth and this tool diff --git a/docs/hooks/repo_links.py b/docs/hooks/repo_links.py index cfa8176..75a521d 100644 --- a/docs/hooks/repo_links.py +++ b/docs/hooks/repo_links.py @@ -51,6 +51,7 @@ "perception/camera-calibration.md": "mote_perception/config/README.md", "perception/benchmarks.md": "mote_perception/benchmarks/README.md", "arm/index.md": "mote_arm/README.md", + "arm/teleop.md": "mote_arm/TELEOP.md", "arm/bench.md": "mote_arm/BENCH.md", "fleet/package.md": "mote_fleet/README.md", "simulation/benchmark.md": "mote_simulation/tools/benchmark/README.md", diff --git a/mkdocs.yml b/mkdocs.yml index 1a54ac8..7c7969f 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -129,6 +129,7 @@ nav: - Camera driver evaluation: research/usb_cam_evaluation.md - SO-101 arm: - Overview: arm/index.md + - Teleop and episodes: arm/teleop.md - Bench validation: arm/bench.md - Fleet: - Operator runbook: fleet/README.md diff --git a/mote_arm/BENCH.md b/mote_arm/BENCH.md index 59157ce..a49f9c0 100644 --- a/mote_arm/BENCH.md +++ b/mote_arm/BENCH.md @@ -346,6 +346,38 @@ exception` on stderr. A `134` is the destroy-while-spinning abort (see README, "Exits and arguments"); it means the tool did its job and then crashed on the way out. +## Step 8 — virtual-leader teleop, recording and replay + +The teleop path has its own guided session, because it needs three terminals +and because three of its checks are observations no script can make (the arm +stopping at a limit, halting on a released key, going limp on panic). + +**Rehearse it headless first** — the same loop runs against the mock follower +with no hardware at all, and a failure there is a software bug, not a bench one: + +``` +pixi run arm-teleop-test +``` + +Then, on the arm: + +``` +# terminal A +pixi run arm mirror:=true +# terminal B +pixi run arm-teleop +# terminal C +pixi run arm-bench-teleop +``` + +Terminal C walks through the safety demonstrations, records an episode while +you teleop it, checks the capture holds a real motion, prints the off-board +export/inspect commands, and replays the episode at quarter speed. It writes +`$MOTE_HOME/episodes/bench/bench-report.txt` — nothing is recorded as passing +that you did not say you saw. + +Full workflow and design: [TELEOP.md](TELEOP.md). + --- ## Sign-off checklist @@ -390,6 +422,9 @@ Still open: - [ ] **the arm moving while the wheels are driving** — the point of the fold. `pixi run robot`, drive a short goal, and jog the arm at the same time; watch for wheel-odometry glitches that would mean the bus is oversubscribed +- [ ] step 8: teleop, record, export/inspect and replay on the arm + (`pixi run arm-bench-teleop`) — verified headless against the mock + control stack, but not yet on hardware - [ ] the other five joints jogged and direction-checked (`invert`) - [ ] re-check the gain with a payload on the gripper — the sweep only measures an unloaded static hold, which is why Kp=64 was taken over a better-scoring diff --git a/mote_arm/README.md b/mote_arm/README.md index 02ba2aa..bd33fc1 100644 --- a/mote_arm/README.md +++ b/mote_arm/README.md @@ -28,6 +28,12 @@ stack). We chose direct Feetech control: same mechanism; we don't need LeRobot's dataset format to unblock the follow-up. If we later want that format for learning, we can convert bags or run LeRobot off-board. + + *That follow-up has since landed, and off-board is what it does* — the robot + writes a stdlib-only capture and `tools/lerobot_export.py` converts it into a + real `LeRobotDataset` in a linux-64 environment of its own. See + [TELEOP.md](TELEOP.md). The decision above is unchanged: LeRobot is where the + dataset goes, not where the arm is driven from. 4. **We can borrow the calibration flow without the framework.** LeRobot's `lerobot-calibrate` is two phases — write each servo's homing offset so mid-travel reads 2048, then record every joint's range in one sweep — and @@ -175,6 +181,8 @@ conversions are verified without hardware. | `calibrate.py` / `arm_calibrate` | Two-phase range calibration: sweep every joint at once, centre its zero, save limits to `$MOTE_HOME/arm.yaml`. Owns the bus: control stack stopped. `pixi run arm-calibrate`. | | `arm_offsets` (tool) | Read/back up/restore/set the servos' position-correction offsets. The recovery path if a calibration is interrupted. `pixi run arm-offsets`. | | `poses.py` / `arm_pose` | Teach and replay named poses, and narrow limits to a working envelope. `pixi run arm-pose save\|list\|go\|limits\|delete`. | +| `mock_arm` (node) | The control stack's interface — trajectory topic and `switch_controller` — with nothing behind it, plus an optional synthetic camera, so teleop, recording and replay run on a workstation. `pixi run arm-mock`. | +| **teleop + episodes** | Virtual-leader teleoperation and LeRobot-format episode recording — `teleop.py`, `virtual_leader`, `arm_mirror`, `episode_record`, `episode_replay`, `tools/lerobot_export.py`. Its own doc: **[TELEOP.md](TELEOP.md)**. | ## Exits and arguments diff --git a/mote_arm/TELEOP.md b/mote_arm/TELEOP.md new file mode 100644 index 0000000..ea849aa --- /dev/null +++ b/mote_arm/TELEOP.md @@ -0,0 +1,292 @@ +# Virtual-leader teleop and episode recording + +Teleoperating an SO-101 normally takes two arms: an operator moves a **leader** +and the **follower** mirrors it. We have one arm and no intention of buying a +second, so the leader here is software — a pose held in a process, moved by the +keyboard, published for the follower to mirror. + +The point of teleoperating at all is the **episodes**: recorded demonstrations +in [LeRobot](https://github.com/huggingface/lerobot)'s dataset format, which is +what a policy would later be learned from. Teleop without recording is just a +slower jog CLI. + +``` + keyboard ─► virtual_leader ─► arm_mirror ─► arm_controller ─► MoteHardware ─► servos + │ leader/joint_states │ arm_controller/joint_trajectory + │ │ + └─────────► episode_record ◄──── /image_raw/compressed + │ + capture dir + ╱ ╲ + lerobot_export episode_replay + (off-board, LeRobot) (back onto the arm) +``` + +## Why this shape + +The task offered three candidate designs. This is the second, and why: + +**LeRobot's own keyboard teleop** would have been cheapest and kept the dataset +story native, but it is not available to us: the [bring-up +decision](README.md#stack-decision-direct-feetech-control-not-lerobot) was +direct Feetech control precisely so that `torch` and the HuggingFace stack stay +off the Pi, the same way inference does. Running LeRobot's teleop means running +LeRobot's robot class, its bus driver and its calibration on the robot — the +thing that decision exists to avoid. LeRobot is still where the *dataset* goes; +it just does not need to be where the *arm* is driven from. That split is the +whole design. + +**End-effector jog with IK** was explicitly out for v1, and rightly: there is no +off-the-shelf SO-101 IK we could drop in, and building one is a separate piece +of work. + +**A virtual leader publishing joint targets** is what is built. Concretely it is +a keyboard frontend, because the bench is reached over SSH and a GUI is not; but +the frontend is deliberately the replaceable part. `arm_mirror` consumes +`leader/joint_states` and nothing else, so a slider GUI, a gamepad, or a script +is a drop-in — see [Other frontends](#other-frontends). + +### Teleop is not jog + +`arm-jog` types a discrete step and presses Enter. Teleop holds a key and the +arm moves continuously until you let go. That difference is the reason this +exists: an episode recorded from stop-start hops teaches a policy stop-start +hops. + +## Safety + +Everything that decides whether the arm may move lives in one place — +`mote_arm/teleop.py`, tested in `test/test_teleop.py` with no hardware attached. + +| Rule | What it does | +|------|--------------| +| **Soft-limit clamping** | A leader pose outside a joint's `robot.yaml` band is clamped before it becomes a goal. Clamped again in the driver, which is authoritative. | +| **Rate limiting** | The commanded pose advances towards the leader by at most `max_velocity * dt` (0.5 rad/s). A leader that *jumps* — a slider dragged, a frontend restarted at a different pose — produces a ramp, never a lunge. | +| **Deadman** | The leader's liveness *is* the deadman. A frontend publishes only while it is being driven, so a released key, a closed window and a dropped SSH session all arrive as the same thing: no fresh pose. The mirror then issues one goal at the arm's *present* position — stopping it there rather than letting it coast to the setpoint it was travelling towards — and then sends nothing. | +| **Panic latch** | `SPACE` publishes a latched e-stop. Torque *is* controller activation, so the mirror deactivates `arm_controller` — the same switch `arm-jog` uses — and refuses every goal until `z` clears it. Torque coming back cannot restart the move; the latch is transient-local, so a mirror restarted mid-panic comes up panicked. | +| **Re-seeding** | Resuming after any hold starts from where the arm *is*, not from the command it was last given. Without that, a pause banks up the difference and pays it out as a jump. | + +One structural consequence worth knowing: **the mirror ticks on its own thread, +not on a ROS timer.** Taking hold of the arm is a `switch_controller` call, and a +service call made from inside an executor callback can never complete — the +future is resolved by the executor that the callback is currently blocking. +`arm-jog` avoids this by driving from its REPL thread; the mirror does the same +with a plain loop while `cli.spin_background` spins the node. + +Two things the deadman is **not**: it is not a debounce (a single key tap moves +the arm for `--key-timeout` seconds — that is the terminal's key-repeat showing +through, and it is bounded at ~0.09 rad by default), and it does not cut torque +(the arm holds its pose; only panic goes limp). + +## The workflow + +Three terminals. Everything but the third also runs against `arm-mock`, which +is how you should rehearse it — see [Without hardware](#without-hardware). + +### 1. Driver and mirror + +```bash +pixi run arm mirror:=true +``` + +`mirror:=true` starts `arm_mirror` alongside the control stack. It is off by +default because `arm-jog`, `arm-pose` and replay all command the same +`arm_controller`, and none of them wants a second thing driving the arm in the +same graph. + +During a mission the arm is already up (`pixi run robot` / `mapping` owns the +bus), so teleop there is just `pixi run arm-mirror` beside it. + +### 2. Teleop + +```bash +pixi run arm-teleop +``` + +``` +hold q/a w/s e/d r/f t/g y/h move joints 1..6 up/down +tap 0 re-sync the leader to where the arm is +tap SPACE PANIC: torque off, latched z clear it +tap [ ] slower / faster ? help x quit +``` + +The leader starts synced to the arm, so nothing moves until you press a key, and +it re-syncs whenever it goes idle — it can never bank up a lead the arm has to +chase after you have stopped. + +`--speed` (default 0.25 rad/s) sets how fast the leader moves; keep it at or +below the mirror's `max_velocity` or the follower is permanently behind. + +### 3. Record + +```bash +pixi run arm-record -- --task "pick up the block" --dataset teleop +``` + +ENTER starts an episode, ENTER stops and keeps it, `r` discards a bad take, `q` +finishes. Recording samples at 20 Hz: + +| Recorded | From | +|----------|------| +| `observation.state` | `joint_states` — where the arm is | +| `observation.images.front` | `/image_raw/compressed`, stored byte-for-byte | +| `action` | `arm_controller/joint_trajectory` — what it was commanded to reach | + +The action is the *mirror's* output, not the leader's pose, because a policy +replaces whatever produces goals — and it is read off the trajectory topic +rather than from the mirror, so a session driven by `arm-jog` records too. + +> **The camera does not physically fit with the arm attached** (GitHub #2). +> Until that is resolved, record state-only with `--no-camera` — the capture, +> the export and the replay all handle a camera-less dataset. + +Captures land in `$MOTE_HOME/episodes//` — per-robot state, alongside +maps, zones and taught poses. The format is documented in `mote_arm/episode.py`: +JSON lines plus the compressed frames, written with nothing but the standard +library, because the Pi carries no parquet or ffmpeg and should not have to. + +### 4. Export to a LeRobot dataset (off-board) + +```bash +pixi run -e lerobot arm-export -- --capture ~/.mote/episodes/teleop \ + --repo-id mote/teleop-demo +``` + +The `lerobot` environment is linux-64 and no-default-feature, for the same +reason `inference` has its own: LeRobot brings torch, ffmpeg and the +HuggingFace stack, none of which belongs on the aarch64 Pi that did the +recording. Copy the capture off the robot (`rsync`) and convert it there. + +The exporter writes through `LeRobotDataset.create` / `add_frame` / +`save_episode` / `finalize` rather than emitting the files itself. The format +has already moved once (v2.1's file-per-episode became v3.0's aggregated +shards); a hand-rolled writer would be a second implementation of someone +else's schema, wrong the first time it changed. + +Two things it does on the way: + +- **Resampling.** LeRobot stores no timestamps — it derives them from the frame + index and the dataset's fps. A capture whose timer slipped would export as if + it had not, silently stretching the motion, so every episode is put on the + exact 1/fps grid first (zero-order hold, never a peek ahead). +- **Decoding.** The stored frames are decoded to RGB here, off-board, where + Pillow exists. + +`--dry-run` reports the schema and the resampled frame counts using only the +capture, so the conversion can be checked on a machine with no LeRobot at all. + +### 5. Inspect it with LeRobot's own tooling + +```bash +pixi run -e lerobot -- lerobot-dataset-viz \ + --repo-id mote/teleop-demo --root --episode-index 0 +``` + +### 6. Replay on the arm + +```bash +pixi run arm-replay -- ~/.mote/episodes/teleop --episode 0 +``` + +Stop the virtual leader first — two things commanding `arm_controller` fight +over the arm. (The stall guard does catch it, which is how that was found, but a +caught stall is not a passing replay.) + +Replay reads the **capture**, not the exported dataset, so it needs nothing +off-board. Three gates, in order: + +1. **Reduced speed** — actions are issued at `fps * --speed-scale`, a quarter of + the recorded rate by default. The same path, not the same dynamics. +2. **Approach, then replay** — the arm is walked to the episode's first pose + first, and the move is refused if that is further than `--max-travel`. +3. **Lag supervision** — the rule that guards `arm-pose go`: if the arm trails + its setpoint for `--stall-time`, the replay stops where it is. + +Every action is clamped to the *current* `robot.yaml` limits, so an episode +recorded before a limit was tightened cannot replay outside today's envelope. + +## Without hardware + +`arm-mock` presents exactly the interface ros2_control does — `joint_states`, +`arm_controller/joint_trajectory`, and `controller_manager/switch_controller` — +with no bus behind it, and `--camera` adds a synthetic camera whose picture +tracks the first joint. It starts limp, as the real stack does, so the first +command is what takes hold. Teleop, recording, export and replay cannot tell the +difference. + +```bash +pixi run arm-mock -- --camera --droop 0.01 # terminal 1 +pixi run arm-mirror # terminal 2 +pixi run arm-teleop # terminal 3 +``` + +`--droop` leaves a constant steady-state error, the way a proportional servo +with `ki = 0` settles under load. Without it the mock lands exactly on every +setpoint and a recorded action is indistinguishable from the observed state. + +The whole loop runs headless as one command: + +```bash +pixi run arm-teleop-test +``` + +It drives the real nodes (mock follower → mirror → `virtual_leader --demo`), +records, checks the capture holds an actual motion, replays it, and plans the +export. Run it before taking anything here to the bench. + +## Verified + +Run on 2026-08-05 against the mock control stack (no arm, no camera). The +hardware half — the three safety *observations* and a real arm retracing an +episode — is step 8 of `BENCH.md` and is still open. + +| Check | Result | +|-------|--------| +| Teleop loop, headless | `pixi run arm-teleop-test`: leader -> mirror -> arm_controller -> arm -> record -> replay -> export plan, all green | +| Taking hold | the mock starts with `arm_controller` inactive, as the real stack spawns it; the first commanded goal activates it | +| Deadman in the loop | the mirror logged `deadman: no leader input, holding position` / `following the leader` on every pause the demo took | +| Two things commanding one arm | caught by the stall guard before the script learned to stop the leader first — the replay halted at 24/220 with 0.209 rad of lag instead of fighting | +| Recording | 220 frames over 10.9 s at 20 fps, 0 dropped ticks, camera frames all distinct | +| Replay | 220 setpoints at half speed, lag steady at 0.010 rad (the mock's droop), finished within 0.0000 rad of the last action | +| Export (camera) | v3.0 dataset: `data/chunk-000/file-000.parquet`, `videos/observation.images.front/chunk-000/file-000.mp4`, `meta/episodes/chunk-000/file-000.parquet` | +| Export (`--no-camera`) | same, state + action only — the path the arm/camera clash forces today | +| Loads back through LeRobot | 1 episode, 220 frames, 20 fps, `so101_follower`; sample shapes `observation.state (6,)`, `action (6,)`, `observation.images.front (3, 72, 96)`, task string intact | +| LeRobot's own viewer | `lerobot-dataset-viz --save 1` read the dataset and wrote a 619 KB `.rrd` | +| Safety rules | 15 unit tests over `teleop.py` (clamp, rate limit, deadman halt-then-silence, re-seed on resume, panic latch) plus 7 node tests through the mirror against the mock | + +The unit tests are the load-bearing ones: every safety rule is decided in +`teleop.py`, so it can be checked exhaustively without a bus. + +## Other frontends + +`arm_mirror` reads `leader/joint_states` and the latched `teleop/estop`, and +that is the entire contract. Anything that publishes a `JointState` of arm joint +names is a leader. For a slider GUI in the dev environment: + +```bash +pixi run -e dev -- ros2 run joint_state_publisher_gui joint_state_publisher_gui \ + --ros-args -r joint_states:=leader/joint_states +``` + +Two caveats, both handled by the mirror rather than by the frontend: the GUI +starts at zero rather than at the arm's pose (the rate limit turns that into a +ramp, but move the sliders to the current pose before it matters), and it +publishes continuously, so its deadman is the window being open rather than a +key being held. + +## Files + +| Piece | What it is | +|-------|------------| +| `teleop.py` | The follow rule — clamping, rate limiting, deadman, panic latch. ROS-free, unit-tested. | +| `virtual_leader.py` | Keyboard frontend (`arm-teleop`). `--demo N` sweeps without a terminal. | +| `mirror.py` | `arm_mirror` — the only thing that turns a leader pose into arm motion. | +| `mock_arm.py` | The control stack's interface with no hardware (`arm-mock`). | +| `episode.py` | The capture format: writer, reader, fps resampling. ROS-free. | +| `episode_record.py` | `arm-record` — observations and actions into a capture. | +| `episode_replay.py` | `arm-replay` — a capture back onto the arm, gated. | +| `motion.py` | Lag supervision, shared with `arm-pose go`. | +| `control.py` | Shared with `arm-jog`: single-point trajectories, and activation as the torque switch. | +| `tools/lerobot_export.py` | Capture → LeRobotDataset, off-board (`-e lerobot`). | +| `test/teleop_loop/` | The headless end-to-end gate (`arm-teleop-test`). | +| `tools/bench_teleop.sh` | The guided hardware session (see `BENCH.md`). | diff --git a/mote_arm/mote_arm/arm_pose.py b/mote_arm/mote_arm/arm_pose.py index f149978..7ea314d 100644 --- a/mote_arm/mote_arm/arm_pose.py +++ b/mote_arm/mote_arm/arm_pose.py @@ -39,6 +39,7 @@ from mote_arm import cli, config, poses from mote_arm.control import ArmControl +from mote_arm.motion import LagSupervisor, lag_of class PoseClient(Node): @@ -233,27 +234,20 @@ def _stream(node: PoseClient, start: dict, goals: dict, args) -> None: f"({args.speed:.2f} rad/s), stopping if lag exceeds {args.max_lag:.2f} rad" ) - lagging = 0.0 + supervisor = LagSupervisor(args.max_lag, args.stall_time) report_every = max(1, len(stream) // 8) for i, setpoint in enumerate(stream, 1): node.send(setpoint, period) time.sleep(period) now = node.current() - lag = max( - (abs(now.get(n, setpoint[n]) - setpoint[n]) for n in setpoint), - default=0.0, - ) - if lag > args.max_lag: - lagging += period - if lagging >= args.stall_time: - print( - f"\nSTOPPED at setpoint {i}/{len(stream)}: the arm trailed by " - f"{lag:.3f} rad for {args.stall_time:.1f}s. Holding here rather " - "than driving against a load it is not overcoming." - ) - return - else: - lagging = 0.0 + lag = lag_of(setpoint, now) + if not supervisor.update(lag, period): + print( + f"\nSTOPPED at setpoint {i}/{len(stream)}: the arm trailed by " + f"{lag:.3f} rad for {args.stall_time:.1f}s. Holding here rather " + "than driving against a load it is not overcoming." + ) + return if i % report_every == 0 or i == len(stream): print( f" {i:>4}/{len(stream)} lag {lag:.4f} rad " diff --git a/mote_arm/mote_arm/episode.py b/mote_arm/mote_arm/episode.py new file mode 100644 index 0000000..70c4832 --- /dev/null +++ b/mote_arm/mote_arm/episode.py @@ -0,0 +1,346 @@ +"""The on-robot capture format for teleoperated episodes, and how to read it. + +A recorded episode has to survive two very different consumers: a policy-learning +stack off-board, which wants a LeRobot dataset, and the robot itself, which wants +to replay the episode on the arm. Writing LeRobot's format directly on the robot +would put parquet, ffmpeg and (transitively) a large ML stack on a Pi that +deliberately carries none of it — the same reason inference runs off-board. + +So the robot writes a **capture**: a plain directory of JSON lines plus the +camera's already-compressed frames, stored byte-for-byte as they were published. +Nothing decodes, re-encodes, or is even aware of an image format. The capture is +the replay source of truth, and ``mote_arm/tools/lerobot_export.py`` turns it +into a real LeRobot dataset off-board, using LeRobot's own writer so validity is +never our reimplementation of someone else's schema. + +Layout, under ``$MOTE_HOME/episodes//``:: + + dataset.json fps, joint order, robot type, camera key/topic + episode_000/ + episode.json task string, frame count, duration, wall clock + frames.jsonl one JSON object per timestep + frames/000000.jpg the compressed image for that timestep + +``frames.jsonl`` is append-only and flushed per row, so a recorder that is +killed mid-episode leaves a readable prefix rather than a corrupt file — the +episode is closed by reading back what actually landed. + +ROS-free: the recorder, the replayer and the off-board exporter all share these +definitions, and the format's tests need no hardware. +""" + +from __future__ import annotations + +import json +from collections.abc import Sequence +from dataclasses import dataclass, field +from pathlib import Path + +CAPTURE_VERSION = 1 + +# LeRobot's convention for a single-arm follower; recorded so the exported +# dataset says what the data came from. +DEFAULT_ROBOT_TYPE = "so101_follower" + + +def episodes_root() -> Path: + """Where captures live: per-robot state, like maps, zones and arm poses. + + ``mote_arm`` deliberately keeps its own reading of ``MOTE_HOME`` rather than + importing ``mote_bringup``'s: the dependency runs `mote_bringup -> mote_arm` + (the base launch resolves this arm's calibration into the URDF) and must not + run back, or the two packages cannot be ordered. Imported inside the + function because the off-board exporter reads captures by explicit path, in + an environment with no ROS packages on it at all. + """ + from mote_arm.poses import mote_home + + return mote_home() / "episodes" + + +@dataclass(frozen=True) +class CameraSpec: + """One camera stream in a capture. + + ``key`` becomes the LeRobot feature suffix (``observation.images.``). + """ + + key: str + topic: str + encoding: str = "jpeg" + + +@dataclass(frozen=True) +class DatasetSpec: + """What every episode in one capture directory has in common.""" + + name: str + fps: int + joints: tuple[str, ...] + robot_type: str = DEFAULT_ROBOT_TYPE + camera: CameraSpec | None = None + version: int = CAPTURE_VERSION + + def to_dict(self) -> dict: + out = { + "version": self.version, + "name": self.name, + "fps": self.fps, + "joints": list(self.joints), + "robot_type": self.robot_type, + "camera": None, + } + if self.camera is not None: + out["camera"] = { + "key": self.camera.key, + "topic": self.camera.topic, + "encoding": self.camera.encoding, + } + return out + + @staticmethod + def from_dict(data: dict) -> "DatasetSpec": + version = int(data.get("version", CAPTURE_VERSION)) + if version != CAPTURE_VERSION: + raise ValueError( + f"capture format version {version} is not {CAPTURE_VERSION} — " + "recorded by a different version of mote_arm" + ) + cam = data.get("camera") + return DatasetSpec( + name=str(data["name"]), + fps=int(data["fps"]), + joints=tuple(str(j) for j in data["joints"]), + robot_type=str(data.get("robot_type", DEFAULT_ROBOT_TYPE)), + camera=( + CameraSpec( + key=str(cam["key"]), + topic=str(cam["topic"]), + encoding=str(cam.get("encoding", "jpeg")), + ) + if cam + else None + ), + version=version, + ) + + +@dataclass(frozen=True) +class Frame: + """One timestep: where the arm was, what it was told, what it saw.""" + + t: float + state: tuple[float, ...] + action: tuple[float, ...] + image: str | None = None + + +@dataclass +class Episode: + path: Path + index: int + task: str + frames: list[Frame] = field(default_factory=list) + + @property + def duration(self) -> float: + return self.frames[-1].t - self.frames[0].t if len(self.frames) > 1 else 0.0 + + def image_path(self, frame: Frame) -> Path | None: + return self.path / frame.image if frame.image else None + + +def _episode_dir(root: Path, index: int) -> Path: + return root / f"episode_{index:03d}" + + +def next_episode_index(root: Path) -> int: + """One past the highest episode already in the directory. + + Indices are never reused: a discarded episode leaves a gap rather than + letting a later recording quietly take over a number that appears in + someone's notes. + """ + if not root.exists(): + return 0 + used = [ + int(p.name.removeprefix("episode_")) + for p in root.iterdir() + if p.is_dir() and p.name.startswith("episode_") and p.name[8:].isdigit() + ] + return max(used) + 1 if used else 0 + + +class EpisodeWriter: + """Appends one episode's frames to disk as they are recorded.""" + + def __init__( + self, root: Path, spec: DatasetSpec, task: str, index: int | None = None + ): + if not task: + raise ValueError("an episode needs a task description") + self.root = Path(root) + self.spec = spec + self.task = task + self.root.mkdir(parents=True, exist_ok=True) + write_dataset_spec(self.root, spec) + + self.index = next_episode_index(self.root) if index is None else index + self.path = _episode_dir(self.root, self.index) + self.path.mkdir(parents=True, exist_ok=False) + if spec.camera is not None: + (self.path / "frames").mkdir() + self._rows = (self.path / "frames.jsonl").open("w") + self.count = 0 + self._first_t: float | None = None + self._last_t = 0.0 + + def add( + self, + stamp: float, + state: Sequence[float], + action: Sequence[float], + image: bytes | None = None, + ) -> None: + """Record one timestep. ``stamp`` is absolute; ``t`` is made relative.""" + if self._first_t is None: + self._first_t = stamp + t = stamp - self._first_t + row: dict = { + "t": round(t, 6), + "state": [float(v) for v in state], + "action": [float(v) for v in action], + } + if image is not None: + if self.spec.camera is None: + raise ValueError("capture has no camera, but an image was recorded") + name = f"frames/{self.count:06d}.{self.spec.camera.encoding}" + (self.path / name).write_bytes(image) + row["image"] = name + self._rows.write(json.dumps(row) + "\n") + # Flushed per row so a killed recorder loses at most the frame in hand. + self._rows.flush() + self.count += 1 + self._last_t = t + + def close(self) -> Path: + """Write the episode's metadata and return its directory.""" + self._rows.close() + (self.path / "episode.json").write_text( + json.dumps( + { + "index": self.index, + "task": self.task, + "frames": self.count, + "duration_s": round(self._last_t, 3), + "fps": self.spec.fps, + }, + indent=2, + ) + + "\n" + ) + return self.path + + def discard(self) -> None: + """Delete a recording that is not worth keeping.""" + self._rows.close() + for child in sorted(self.path.rglob("*"), reverse=True): + child.unlink() if child.is_file() else child.rmdir() + self.path.rmdir() + + +def write_dataset_spec(root: Path, spec: DatasetSpec) -> None: + (Path(root) / "dataset.json").write_text( + json.dumps(spec.to_dict(), indent=2) + "\n" + ) + + +def load_dataset_spec(root: Path | str) -> DatasetSpec: + path = Path(root) / "dataset.json" + if not path.exists(): + raise FileNotFoundError(f"{root} is not a capture directory (no dataset.json)") + return DatasetSpec.from_dict(json.loads(path.read_text())) + + +def load_episode(path: Path | str) -> Episode: + """Read one episode directory, tolerating a truncated final row. + + A recorder killed mid-write leaves a partial last line; the frames before it + are perfectly good data, and refusing to load them would throw away a whole + session over the last 50 ms of it. + """ + path = Path(path) + meta_path = path / "episode.json" + meta = json.loads(meta_path.read_text()) if meta_path.exists() else {} + + frames: list[Frame] = [] + for line in (path / "frames.jsonl").read_text().splitlines(): + if not line.strip(): + continue + try: + row = json.loads(line) + except json.JSONDecodeError: + break + frames.append( + Frame( + t=float(row["t"]), + state=tuple(float(v) for v in row["state"]), + action=tuple(float(v) for v in row["action"]), + image=row.get("image"), + ) + ) + + index = int(meta.get("index", int(path.name.removeprefix("episode_") or 0))) + return Episode( + path=path, + index=index, + task=str(meta.get("task", "")), + frames=frames, + ) + + +def list_episodes(root: Path | str) -> list[Path]: + root = Path(root) + if not root.exists(): + return [] + return sorted( + p for p in root.iterdir() if p.is_dir() and p.name.startswith("episode_") + ) + + +def resample(frames: Sequence[Frame], fps: int) -> list[Frame]: + """Put frames on the exact 1/fps grid a LeRobot dataset assumes. + + LeRobot derives each frame's timestamp from its index and the dataset's fps — + the recorded times are never stored, only implied. A capture whose timer + slipped would therefore be exported as if it had not, silently stretching or + compressing the motion. Resampling first makes the implied timeline the true + one: each grid point takes the most recent frame at or before it, which is + the causal choice (never showing an observation or action before it existed). + """ + if fps <= 0: + raise ValueError("fps must be positive") + if not frames: + return [] + + period = 1.0 / fps + duration = frames[-1].t - frames[0].t + count = int(round(duration / period)) + 1 + base = frames[0].t + + out: list[Frame] = [] + cursor = 0 + for k in range(count): + target = base + k * period + while cursor + 1 < len(frames) and frames[cursor + 1].t <= target + 1e-9: + cursor += 1 + src = frames[cursor] + out.append( + Frame( + t=round(k * period, 6), + state=src.state, + action=src.action, + image=src.image, + ) + ) + return out diff --git a/mote_arm/mote_arm/episode_record.py b/mote_arm/mote_arm/episode_record.py new file mode 100644 index 0000000..d5a1812 --- /dev/null +++ b/mote_arm/mote_arm/episode_record.py @@ -0,0 +1,293 @@ +"""Record teleoperated episodes: what the arm saw, where it was, what it was told. + +An episode is a demonstration — the raw material a policy is later learned from — +so what gets stored is fixed by what a policy needs at inference time: + + observation.state the arm's measured joint positions + observation.images. the camera frame, stored exactly as published + action the joint positions it was commanded to reach + +The action is what reached ``arm_controller`` — the mirror's output, not the +leader's pose: a policy replaces the thing that produces goals, so the goals are +the thing to imitate. It is read off the trajectory topic rather than from the +mirror, so a session driven by ``arm-jog`` or by anything else records just as +well. Before the first goal of an episode arrives the action is the measured +state — "stay where you are" is what the arm was, in fact, being told. + +Sampling is timer-driven at the dataset's fps and takes the most recent value of +each input, so a 10 Hz camera under a 20 Hz recording repeats frames rather than +leaving holes. That is the same thing a real teleop rig does, and it keeps every +row complete. + +Interactive by default — ENTER starts and stops an episode, so both hands are +free for the arm between takes. ``--duration`` records fixed-length episodes +without a human, which is how the mock-arm tests and the bench script drive it. +""" + +from __future__ import annotations + +import argparse +import sys +import threading +import time +from pathlib import Path + +import rclpy +from rclpy.node import Node +from sensor_msgs.msg import CompressedImage, JointState +from trajectory_msgs.msg import JointTrajectory + +from mote_arm import cli, config +from mote_arm.control import TRAJECTORY_TOPIC +from mote_arm.episode import CameraSpec, DatasetSpec, EpisodeWriter, episodes_root + +DEFAULT_CAMERA_TOPIC = "/image_raw/compressed" +DEFAULT_CAMERA_KEY = "front" + + +def encoding_of(image_format: str) -> str: + """The file extension for a CompressedImage, read off its format field. + + image_transport publishes ``"; compressed <...>"``. We + store the bytes untouched, so all we need from that is what to call the file. + """ + lowered = image_format.lower() + for codec in ("jpeg", "jpg", "png", "webp", "tiff"): + if codec in lowered: + return "jpeg" if codec == "jpg" else codec + return "bin" + + +class EpisodeRecorder(Node): + def __init__(self, args): + super().__init__("episode_record") + self.declare_parameter("robot_yaml", "") + path = self.get_parameter("robot_yaml").get_parameter_value().string_value + self.cfg = config.ArmConfig.from_yaml_file(path) if path else config.load() + + self.args = args + self.root = Path(args.root) if args.root else episodes_root() / args.dataset + self._lock = threading.Lock() + self._state: dict[str, float] = {} + self._action: dict[str, float] = {} + self._image: bytes | None = None + self._image_encoding: str | None = None + self.writer: EpisodeWriter | None = None + self.dropped = 0 + + self.create_subscription(JointState, "joint_states", self._on_states, 10) + self.create_subscription(JointTrajectory, TRAJECTORY_TOPIC, self._on_goal, 10) + if args.camera: + self.create_subscription( + CompressedImage, args.camera_topic, self._on_image, 5 + ) + + self.create_timer(1.0 / args.fps, self._tick) + + def _on_states(self, msg: JointState) -> None: + names = set(self.cfg.names) + with self._lock: + for name, position in zip(msg.name, msg.position): + if name in names: + self._state[name] = position + + def _on_goal(self, msg: JointTrajectory) -> None: + """Record where a commanded trajectory ends up. + + Only the final point matters: the action a policy learns is the pose the + arm was asked to be in, not the interpolation used to get there. + """ + if not msg.points: + return + names = set(self.cfg.names) + with self._lock: + for name, position in zip(msg.joint_names, msg.points[-1].positions): + if name in names: + self._action[name] = position + + def _on_image(self, msg: CompressedImage) -> None: + with self._lock: + self._image = bytes(msg.data) + self._image_encoding = encoding_of(msg.format) + + def sample(self) -> tuple[list[float], list[float], bytes | None] | None: + """The current row, or None if the arm is not fully reporting.""" + with self._lock: + if any(name not in self._state for name in self.cfg.names): + return None + state = [self._state[n] for n in self.cfg.names] + # No goal yet: the arm is being told to hold where it is. + action = [self._action.get(n, self._state[n]) for n in self.cfg.names] + return state, action, self._image + + def ready(self, timeout: float = 10.0) -> str | None: + """Block until every input has been seen; returns a reason if it hasn't.""" + deadline = time.time() + timeout + have_state = False + while time.time() < deadline: + with self._lock: + have_state = all(n in self._state for n in self.cfg.names) + have_image = self._image is not None + if have_state and (have_image or not self.args.camera): + return None + time.sleep(0.1) + if not have_state: + missing = sorted(set(self.cfg.names) - set(self._state)) + return ( + f"no joint_states for {missing} — is a stack that owns the servo " + "bus running (`pixi run arm`, or `pixi run robot`)?" + ) + return ( + f"no frames on {self.args.camera_topic} — start the camera, or record " + "state-only with --no-camera" + ) + + def start(self, task: str) -> EpisodeWriter: + with self._lock: + encoding = self._image_encoding or "jpeg" + camera = ( + CameraSpec( + key=self.args.camera_key, + topic=self.args.camera_topic, + encoding=encoding, + ) + if self.args.camera + else None + ) + spec = DatasetSpec( + name=self.args.dataset, + fps=self.args.fps, + joints=tuple(self.cfg.names), + camera=camera, + ) + self.dropped = 0 + writer = EpisodeWriter(self.root, spec, task) + self.writer = writer + return writer + + def stop(self, keep: bool = True) -> Path | None: + writer, self.writer = self.writer, None + if writer is None: + return None + if keep: + return writer.close() + writer.discard() + return None + + def _tick(self) -> None: + writer = self.writer + if writer is None: + return + row = self.sample() + if row is None: + # A gap in joint_states must not become a silently wrong frame; drop + # the tick and account for it, so a sparse episode is visible. + self.dropped += 1 + return + state, action, image = row + writer.add(time.monotonic(), state, action, image if self.args.camera else None) + + +def _record_one( + node: EpisodeRecorder, task: str, duration: float | None +) -> Path | None: + writer = node.start(task) + print(f"recording episode {writer.index} ...", flush=True) + if duration is not None: + time.sleep(duration) + keep = True + else: + reply = input("ENTER to stop and keep, 'r' ENTER to discard: ").strip().lower() + keep = reply != "r" + path = node.stop(keep=keep) + if path is None: + print("discarded") + return None + frames = writer.count + print( + f"saved {path} — {frames} frames" + + (f", {node.dropped} ticks dropped (no joint_states)" if node.dropped else "") + ) + if frames == 0: + print("warning: the episode is empty and will export as nothing") + return path + + +def _session(node: EpisodeRecorder, args) -> None: + camera = f", {args.camera_topic}" if args.camera else " (no camera)" + print(f"dataset: {node.root}") + print(f"task: {args.task!r}") + print(f"inputs: joint_states, {TRAJECTORY_TOPIC}{camera}") + problem = node.ready() + if problem: + raise SystemExit(problem) + + recorded = 0 + while args.episodes is None or recorded < args.episodes: + if args.duration is None: + reply = input("\nENTER to record episode, 'q' to finish: ").strip().lower() + if reply == "q": + break + if _record_one(node, args.task, args.duration) is not None: + recorded += 1 + if args.duration is not None and ( + args.episodes is None or recorded >= args.episodes + ): + break + print(f"\n{recorded} episode(s) in {node.root}") + print(f"export with: pixi run -e lerobot arm-export -- --capture {node.root}") + + +def main() -> None: + parser = argparse.ArgumentParser(description="Record teleoperated arm episodes") + parser.add_argument("--task", required=True, help="what the demonstration shows") + parser.add_argument( + "--dataset", default="teleop", help="capture name (default teleop)" + ) + parser.add_argument( + "--root", + default=None, + help="capture directory (default $MOTE_HOME/episodes/)", + ) + parser.add_argument( + "--fps", type=int, default=20, help="sampling rate (default 20)" + ) + parser.add_argument( + "--episodes", type=int, default=None, help="stop after N episodes" + ) + parser.add_argument( + "--duration", + type=float, + default=None, + help="record fixed-length episodes without prompting (seconds)", + ) + parser.add_argument("--camera-topic", default=DEFAULT_CAMERA_TOPIC) + parser.add_argument( + "--camera-key", default=DEFAULT_CAMERA_KEY, help="LeRobot feature suffix" + ) + parser.add_argument( + "--no-camera", + dest="camera", + action="store_false", + help="record state and action only (the camera does not fit with the arm attached)", + ) + args = cli.parse(parser) + + rclpy.init() + node = EpisodeRecorder(args) + + spinner = cli.spin_background(node) + try: + _session(node, args) + except (KeyboardInterrupt, EOFError): + print("\ninterrupted", file=sys.stderr) + finally: + # An interrupted episode is still data: close it rather than lose it. + path = node.stop(keep=True) + if path is not None: + print(f"closed in-progress episode: {path}") + cli.shutdown(node, spinner) + + +if __name__ == "__main__": + main() diff --git a/mote_arm/mote_arm/episode_replay.py b/mote_arm/mote_arm/episode_replay.py new file mode 100644 index 0000000..39fd152 --- /dev/null +++ b/mote_arm/mote_arm/episode_replay.py @@ -0,0 +1,270 @@ +"""Replay a recorded episode on the arm. + +Replay is the honest test of a recording: if the stored actions put the arm back +through the demonstrated motion, the episode really does contain what a policy +would need to learn from. It is also the first thing that will run a *policy's* +output, so it is built to be the safe version of that path from the start. + +Three gates, in order: + +1. **Reduced speed.** Actions are issued at ``fps * --speed-scale`` (a quarter + of the recorded rate by default), so a replay is slow enough to watch and to + interrupt. It replays the same *path*, not the same dynamics. +2. **Approach, then replay.** The arm is walked to the episode's first pose + before anything is replayed, at a bounded speed and only after the operator + has seen how far that is. Replaying from wherever the arm happens to be + parked would put the first action a long way from it. +3. **Lag supervision.** The same rule that guards ``arm-pose go``: if the arm + trails its setpoint for ``--stall-time``, the replay stops where it is rather + than driving on against whatever is holding it. + +Every action is clamped to the robot.yaml soft limits here and again in the +hardware, so an episode recorded before a limit was tightened cannot replay +outside the current envelope. + +Stop the virtual leader before replaying — two things commanding +``arm_controller`` would fight over the arm. +""" + +from __future__ import annotations + +import argparse +import sys +import threading +import time +from pathlib import Path + +import rclpy +from rclpy.node import Node +from sensor_msgs.msg import JointState + +from mote_arm import cli, config, poses +from mote_arm.control import ArmControl +from mote_arm.episode import Episode, list_episodes, load_dataset_spec, load_episode +from mote_arm.motion import LagSupervisor, lag_of + + +class ReplayClient(Node): + """A client of arm_controller — commands the arm, never touches the bus.""" + + def __init__(self): + super().__init__("episode_replay") + self.declare_parameter("robot_yaml", "") + path = self.get_parameter("robot_yaml").get_parameter_value().string_value + self.cfg = config.ArmConfig.from_yaml_file(path) if path else config.load() + self._lock = threading.Lock() + self._measured: dict[str, float] = {} + self.create_subscription(JointState, "joint_states", self._on_states, 10) + self.arm = ArmControl(self) + + def _on_states(self, msg: JointState) -> None: + names = set(self.cfg.names) + with self._lock: + for name, position in zip(msg.name, msg.position): + if name in names: + self._measured[name] = position + + def measured(self) -> dict[str, float]: + with self._lock: + return dict(self._measured) + + def wait_for_states(self, timeout: float = 5.0) -> bool: + deadline = time.time() + timeout + while time.time() < deadline: + if len(self.measured()) == len(self.cfg.names): + return True + time.sleep(0.05) + return False + + def send(self, pose: dict[str, float], seconds: float) -> bool: + """Command one setpoint, taking hold of the arm first if it is limp.""" + return self.arm.send(pose, seconds) + + +def _resolve(capture: Path, index: int | None) -> Path: + """The episode directory to replay: named by index, or the only one there.""" + available = list_episodes(capture) + if not available: + raise SystemExit(f"no episodes in {capture}") + if index is None: + if len(available) > 1: + names = ", ".join(p.name for p in available) + raise SystemExit( + f"{capture} holds several episodes ({names}) — pass --episode N" + ) + return available[0] + match = capture / f"episode_{index:03d}" + if not match.exists(): + raise SystemExit(f"no episode {index} in {capture}") + return match + + +def _pose_of(joints: tuple[str, ...], values) -> dict[str, float]: + return dict(zip(joints, values)) + + +def _stream( + node: ReplayClient, + setpoints: list[dict[str, float]], + period: float, + supervisor: LagSupervisor, + label: str, +) -> bool: + """Issue setpoints on a fixed period; False if the arm stopped keeping up.""" + report_every = max(1, len(setpoints) // 8) + for i, setpoint in enumerate(setpoints, 1): + if not node.send(setpoint, period): + print( + f"\nSTOPPED during {label} at {i}/{len(setpoints)}: could not take " + "hold of the arm, so nothing further was commanded." + ) + return False + time.sleep(period) + lag = lag_of(setpoint, node.measured()) + if not supervisor.update(lag, period): + print( + f"\nSTOPPED during {label} at {i}/{len(setpoints)}: the arm trailed " + f"by {lag:.3f} rad for {supervisor.stall_time:.1f}s. Holding here " + "rather than driving against a load it is not overcoming." + ) + return False + if i % report_every == 0 or i == len(setpoints): + print(f" {label} {i:>5}/{len(setpoints)} lag {lag:.4f} rad") + return True + + +def _summarise(episode: Episode, joints: tuple[str, ...], cfg) -> dict[str, float]: + """Print what the episode will do and return its first (clamped) pose.""" + actions = [_pose_of(joints, frame.action) for frame in episode.frames] + print( + f"\nepisode {episode.index}: {len(episode.frames)} frames, {episode.duration:.1f}s" + ) + print(f"task: {episode.task!r}") + for name in joints: + values = [pose[name] for pose in actions] + joint = cfg.joint(name) + clamped = any(joint.clamp_rad(v) != v for v in values) + print( + f" {name:<14} {min(values):+.3f} .. {max(values):+.3f} rad" + + (" (CLAMPED to limits on replay)" if clamped else "") + ) + return {n: cfg.joint(n).clamp_rad(v) for n, v in actions[0].items()} + + +def _run(node: ReplayClient, args) -> None: + capture = Path(args.capture) + spec = load_dataset_spec(capture) + episode = load_episode(_resolve(capture, args.episode)) + if not episode.frames: + raise SystemExit(f"episode {episode.path} has no frames") + + unknown = [n for n in spec.joints if n not in set(node.cfg.names)] + if unknown: + raise SystemExit( + f"episode was recorded with joints {unknown} that this arm does not " + "have — it belongs to a different robot.yaml" + ) + + if not node.wait_for_states(): + raise SystemExit( + "no /joint_states for all arm joints — is a stack that owns the servo " + "bus running (`pixi run arm`, or `pixi run robot`)?" + ) + current = node.measured() + start = _summarise(episode, spec.joints, node.cfg) + + approach = max(abs(start[n] - current[n]) for n in start if n in current) + print(f"\napproach to the first pose: {approach:.3f} rad of travel") + if approach > args.max_travel: + raise SystemExit( + f"refusing: the arm is {approach:.3f} rad from the episode's start, over " + f"--max-travel {args.max_travel:.3f}. Move it closer, or raise the limit " + "deliberately." + ) + + period = 1.0 / (spec.fps * args.speed_scale) + print( + f"replay at {args.speed_scale:.0%} of {spec.fps} fps " + f"({1 / period:.1f} setpoints/s), stopping if lag exceeds {args.max_lag:.2f} rad" + ) + if not args.yes and input("proceed? [y/N] ").strip().lower() not in ("y", "yes"): + print("aborted; nothing sent") + return + + supervisor = LagSupervisor(args.max_lag, args.stall_time) + walk = poses.interpolate(current, start, max(1e-4, args.approach_speed / spec.fps)) + if walk and not _stream(node, walk, 1.0 / spec.fps, supervisor, "approach"): + return + + setpoints = [ + { + n: node.cfg.joint(n).clamp_rad(v) + for n, v in _pose_of(spec.joints, frame.action).items() + } + for frame in episode.frames + ] + if not _stream(node, setpoints, period, supervisor, "replay"): + return + + final = node.measured() + print("\nfinal pose vs the episode's last action:") + for name in spec.joints: + target = setpoints[-1][name] + print( + f" {name:<14} {final.get(name, float('nan')):+.4f} rad " + f"(target {target:+.4f}, err {final.get(name, float('nan')) - target:+.4f})" + ) + + +def main() -> None: + parser = argparse.ArgumentParser(description="Replay a recorded episode on the arm") + parser.add_argument( + "capture", help="capture directory ($MOTE_HOME/episodes/)" + ) + parser.add_argument("--episode", type=int, default=None, help="episode index") + parser.add_argument("--yes", action="store_true", help="skip the confirmation") + parser.add_argument( + "--speed-scale", + type=float, + default=0.25, + help="fraction of the recorded rate to replay at (default 0.25)", + ) + parser.add_argument( + "--approach-speed", + type=float, + default=0.3, + help="rad/s for the walk to the episode's first pose (default 0.3)", + ) + parser.add_argument( + "--max-travel", + type=float, + default=0.35, + help="refuse if the approach would move a joint further than this (default 0.35)", + ) + parser.add_argument("--max-lag", type=float, default=0.15) + parser.add_argument("--stall-time", type=float, default=1.5) + args = cli.parse(parser) + + if not 0 < args.speed_scale <= 1.0: + raise SystemExit( + "--speed-scale must be in (0, 1]; replay never speeds an episode up" + ) + + rclpy.init() + node = ReplayClient() + + spinner = cli.spin_background(node) + try: + _run(node, args) + except KeyboardInterrupt: + print("\ninterrupted — the arm holds its last setpoint", file=sys.stderr) + finally: + # Replay took hold of the arm, so replay gives it back: leaving a + # torqued arm behind an exited process is how a bench session ends with + # the arm holding a pose nobody is watching. + node.arm.set_holding(False) + cli.shutdown(node, spinner) + + +if __name__ == "__main__": + main() diff --git a/mote_arm/mote_arm/mirror.py b/mote_arm/mote_arm/mirror.py new file mode 100644 index 0000000..5f4c69f --- /dev/null +++ b/mote_arm/mote_arm/mirror.py @@ -0,0 +1,155 @@ +"""The mirror node: the only thing that turns a virtual leader into arm motion. + +It subscribes to a leader pose, the arm's measured state and the e-stop flag, +and commands `arm_controller` through `mote_arm.control`. Every safety rule +lives in `mote_arm.teleop.LeaderMirror` (clamping, rate limiting, the deadman, +the panic latch) so it can be tested without a bus, a controller, or a terminal; +this node is the ROS wiring around it. + +Keeping it separate from the frontend is what makes the frontend replaceable: +the keyboard leader, a slider GUI publishing `leader/joint_states`, or a +recorded episode being replayed are all the same thing from here. + +**Panic is controller deactivation, and it latches.** Since `MoteHardware` takes +hold of the arm exactly when `arm_controller` claims its command interfaces, +dropping torque means deactivating the controller — the same switch `arm-jog` +uses. The latch then suppresses every goal until it is explicitly cleared, so +the arm cannot resume simply because input started arriving again. + +**The tick loop runs on the main thread, not on a timer.** Taking hold of the +arm is a `switch_controller` service call, and a service call made from inside +an executor callback can never complete: the future is resolved by the executor +that the callback is currently blocking. `arm-jog` gets this right by driving +from its REPL thread; the mirror does the same with a plain loop while +`cli.spin_background` spins the node. +""" + +from __future__ import annotations + +import time + +import rclpy +from rclpy.node import Node +from rclpy.qos import DurabilityPolicy, QoSProfile +from sensor_msgs.msg import JointState +from std_msgs.msg import Bool + +from mote_arm import cli, config +from mote_arm.control import ArmControl +from mote_arm.teleop import ESTOPPED, HOLDING, TRACKING, LeaderMirror, MirrorLimits + + +def latched(depth: int = 1) -> QoSProfile: + """Transient-local QoS for the e-stop flag. + + The latch has to outlive the process that set it: a mirror restarted while + the arm is e-stopped must come up e-stopped, not come up following. + """ + qos = QoSProfile(depth=depth) + qos.durability = DurabilityPolicy.TRANSIENT_LOCAL + return qos + + +class ArmMirror(Node): + def __init__(self): + super().__init__("arm_mirror") + self.declare_parameter("robot_yaml", "") + self.declare_parameter("rate", 20.0) + self.declare_parameter("max_velocity", MirrorLimits.max_velocity) + self.declare_parameter("deadman_timeout", MirrorLimits.deadman_timeout) + + path = self.get_parameter("robot_yaml").get_parameter_value().string_value + self.cfg = config.ArmConfig.from_yaml_file(path) if path else config.load() + + self.mirror = LeaderMirror( + self.cfg.joints, + MirrorLimits( + max_velocity=self.get_parameter("max_velocity").value, + deadman_timeout=self.get_parameter("deadman_timeout").value, + ), + ) + + self.arm = ArmControl(self) + self.create_subscription(JointState, "leader/joint_states", self._on_leader, 10) + self.create_subscription(JointState, "joint_states", self._on_states, 10) + self.create_subscription(Bool, "teleop/estop", self._on_estop, latched()) + + self._reported = None + self._estop_requested = False + self.period = 1.0 / max(1.0, self.get_parameter("rate").value) + + limits = self.mirror.limits + self.get_logger().info( + f"arm_mirror up: max {limits.max_velocity:.2f} rad/s, deadman " + f"{limits.deadman_timeout:.2f} s — leader/joint_states -> arm_controller" + ) + + def _now(self) -> float: + return self.get_clock().now().nanoseconds * 1e-9 + + def _on_leader(self, msg: JointState) -> None: + self.mirror.on_leader(dict(zip(msg.name, msg.position)), self._now()) + + def _on_states(self, msg: JointState) -> None: + self.mirror.on_measured(dict(zip(msg.name, msg.position))) + + def _on_estop(self, msg: Bool) -> None: + # Recorded here, acted on in the loop: dropping torque is a service + # call, which cannot complete from inside this callback. + self._estop_requested = msg.data + + def _apply_estop(self) -> None: + if self._estop_requested == self.mirror.estopped: + return + self.mirror.set_estop(self._estop_requested, self._now()) + if self._estop_requested: + self.get_logger().warn("PANIC: dropping torque and refusing goals") + if not self.arm.set_holding(False): + self.get_logger().error( + "could not deactivate arm_controller — the arm may still be " + "holding; stop the control stack or cut power" + ) + else: + self.get_logger().info("panic cleared; following again") + + def tick(self) -> None: + self._apply_estop() + goal = self.mirror.update(self._now(), self.period) + if goal: + # One period to reach the point: the mirror has already rate-limited + # the step to what that allows, and a trajectory the arm cannot + # finish in time just runs ahead of the hardware. + self.arm.send(goal, self.period) + + if self.mirror.state != self._reported: + self._reported = self.mirror.state + if self.mirror.state == HOLDING: + self.get_logger().info("deadman: no leader input, holding position") + elif self.mirror.state == TRACKING: + self.get_logger().info("following the leader") + elif self.mirror.state == ESTOPPED: + self.get_logger().warn("e-stopped") + + def run(self) -> None: + while rclpy.ok(): + self.tick() + time.sleep(self.period) + + +def main() -> None: + rclpy.init() + node = ArmMirror() + spinner = cli.spin_background(node) + try: + node.run() + except KeyboardInterrupt: + pass + finally: + # Leave the arm limp: the mirror took hold of it, so the mirror gives it + # back rather than leaving a torqued arm behind an exited process. + node.arm.set_holding(False) + cli.shutdown(node, spinner) + + +if __name__ == "__main__": + main() diff --git a/mote_arm/mote_arm/mock_arm.py b/mote_arm/mote_arm/mock_arm.py new file mode 100644 index 0000000..6f01a64 --- /dev/null +++ b/mote_arm/mote_arm/mock_arm.py @@ -0,0 +1,228 @@ +"""An arm control stack that isn't there: the interface, without the bus. + +Teleoperation, recording, export and replay are four pieces that have to work +together, and none of them should need a physical arm — or a physical camera — +to be exercised. This node presents the same surface the real stack does, which +since arm control folded into `MoteHardware` is ros2_control's, not a driver's: + + publishes joint_states (sensor_msgs/JointState) + subscribes arm_controller/joint_trajectory (trajectory_msgs/JointTrajectory) + serves controller_manager/switch_controller + +so `mote_arm.control.ArmControl` — and therefore the mirror, `arm-jog`, +`arm-pose` and episode replay — cannot tell the difference. It also optionally +publishes a synthetic `image_raw/compressed` whose content tracks the first +joint, so a recorded episode has camera frames that actually change and an +exported dataset is worth looking at. + +Like the real thing it starts **limp**: `arm_controller` is inactive until a +client activates it, which is what makes MoteHardware take hold. And like a real +position servo it can be told to settle `--droop` short of its goal, which is +what a proportional loop with `ki = 0` does under load — enough to exercise the +replayer's lag supervision honestly. It is a stand-in for the *control stack*, +not a simulation of the arm: a limp mock does not fall over. + +Run it instead of `pixi run arm`: `pixi run arm-mock`. +""" + +from __future__ import annotations + +import argparse +import math +import struct +import zlib + +import rclpy +from controller_manager_msgs.srv import SwitchController +from rclpy.executors import ExternalShutdownException +from rclpy.node import Node +from sensor_msgs.msg import CompressedImage, JointState +from trajectory_msgs.msg import JointTrajectory + +from mote_arm import cli, config +from mote_arm.control import ARM_CONTROLLER, SWITCH_SERVICE, TRAJECTORY_TOPIC +from mote_arm.motion import advance + + +def _png(width: int, height: int, pixels: bytes) -> bytes: + """Encode RGB bytes as a PNG, using nothing but zlib and struct. + + The mock has to produce a real, decodable image without dragging an imaging + library onto the robot environment just so a fake camera can exist. PNG is + the one format whose encoder is a handful of lines. + """ + + def chunk(kind: bytes, payload: bytes) -> bytes: + return ( + struct.pack(">I", len(payload)) + + kind + + payload + + struct.pack(">I", zlib.crc32(kind + payload) & 0xFFFFFFFF) + ) + + stride = width * 3 + raw = b"".join( + b"\x00" + pixels[row * stride : (row + 1) * stride] for row in range(height) + ) + return ( + b"\x89PNG\r\n\x1a\n" + + chunk(b"IHDR", struct.pack(">IIBBBBB", width, height, 8, 2, 0, 0, 0)) + + chunk(b"IDAT", zlib.compress(raw, 6)) + + chunk(b"IEND", b"") + ) + + +class MockArm(Node): + def __init__(self, args): + super().__init__("mock_arm") + self.declare_parameter("robot_yaml", "") + path = self.get_parameter("robot_yaml").get_parameter_value().string_value + self.cfg = config.ArmConfig.from_yaml_file(path) if path else config.load() + + self.args = args + self.position = {j.name: j.clamp_rad(0.0) for j in self.cfg.joints} + self.goal = dict(self.position) + # Per joint, rad/s towards the goal: a trajectory says when to arrive, + # not how fast, so the speed is whatever that duration implies. + self.rate = dict.fromkeys(self.position, 0.0) + # Limp until a client activates arm_controller, exactly as the real + # stack spawns it. + self.holding = False + + self._pub = self.create_publisher(JointState, "joint_states", 10) + self.create_subscription( + JointTrajectory, TRAJECTORY_TOPIC, self._on_trajectory, 10 + ) + self.create_service(SwitchController, SWITCH_SERVICE, self._on_switch) + self._period = 1.0 / args.rate + self.create_timer(self._period, self._tick) + + self._camera = None + if args.camera: + self._camera = self.create_publisher( + CompressedImage, "image_raw/compressed", 5 + ) + self.create_timer(1.0 / args.camera_rate, self._publish_image) + + self.get_logger().info( + f"mock_arm up: {len(self.cfg.joints)} joints, no hardware" + + (", synthetic camera" if args.camera else "") + + f" ({ARM_CONTROLLER} inactive — the arm is limp)" + ) + + def _on_trajectory(self, msg: JointTrajectory) -> None: + if not msg.points: + return + point = msg.points[-1] + seconds = point.time_from_start.sec + point.time_from_start.nanosec * 1e-9 + for name, value in zip(msg.joint_names, point.positions): + try: + joint = self.cfg.joint(name) + except KeyError: + self.get_logger().warn(f"ignoring goal for unknown joint '{name}'") + continue + target = joint.clamp_rad(value) + self.goal[name] = target + travel = abs(target - self.position[name]) + self.rate[name] = min(self.args.speed, travel / max(seconds, self._period)) + + def _on_switch(self, request, response): + if ARM_CONTROLLER in request.activate_controllers: + self.holding = True + if ARM_CONTROLLER in request.deactivate_controllers: + self.holding = False + response.ok = True + return response + + def _tick(self) -> None: + if self.holding: + for name, target in self.goal.items(): + # Stop `droop` short of the goal, the way a proportional servo + # with no integral term settles under a holding load. + current = self.position[name] + remaining = target - current + if abs(remaining) <= self.args.droop: + continue + short = target - math.copysign(self.args.droop, remaining) + self.position[name] = advance( + current, short, self.rate[name] * self._period + ) + + msg = JointState() + msg.header.stamp = self.get_clock().now().to_msg() + msg.name = list(self.position) + msg.position = [self.position[n] for n in msg.name] + self._pub.publish(msg) + + def _publish_image(self) -> None: + width, height = self.args.camera_size + first = self.cfg.joints[0] + span = max(1e-6, first.max_rad - first.min_rad) + fraction = (self.position[first.name] - first.min_rad) / span + bar = min(width - 1, max(0, int(fraction * (width - 1)))) + + rows = [] + for y in range(height): + row = bytearray() + for x in range(width): + if abs(x - bar) <= 1: + row += b"\xff\xd0\x20" + else: + row += bytes((x * 255 // width, y * 255 // height, 64)) + rows.append(bytes(row)) + + msg = CompressedImage() + msg.header.stamp = self.get_clock().now().to_msg() + msg.format = "rgb8; png compressed rgb8" + msg.data = _png(width, height, b"".join(rows)) + self._camera.publish(msg) + + +def _size(text: str) -> tuple[int, int]: + width, _, height = text.partition("x") + return int(width), int(height) + + +def main() -> None: + parser = argparse.ArgumentParser( + description="Mock SO-101 control stack (no hardware)" + ) + parser.add_argument("--rate", type=float, default=20.0, help="joint_states Hz") + parser.add_argument( + "--speed", + type=float, + default=1.0, + help="rad/s the mock will not exceed, whatever a trajectory asks (default 1.0)", + ) + parser.add_argument( + "--droop", + type=float, + default=0.0, + help="radians of steady-state error to leave, as a real servo does (default 0)", + ) + parser.add_argument( + "--camera", action="store_true", help="publish a synthetic camera" + ) + parser.add_argument("--camera-rate", type=float, default=10.0) + parser.add_argument("--camera-size", type=_size, default=(96, 72)) + args = cli.parse(parser) + + rclpy.init() + node = None + try: + node = MockArm(args) + rclpy.spin(node) + except (KeyboardInterrupt, ExternalShutdownException): + pass + except Exception: # noqa: BLE001 - the context is gone, see cli.spin_background + if rclpy.ok(): + raise + finally: + if node is not None: + node.destroy_node() + if rclpy.ok(): + rclpy.shutdown() + + +if __name__ == "__main__": + main() diff --git a/mote_arm/mote_arm/motion.py b/mote_arm/mote_arm/motion.py new file mode 100644 index 0000000..538ea84 --- /dev/null +++ b/mote_arm/mote_arm/motion.py @@ -0,0 +1,66 @@ +"""Supervision shared by everything that streams setpoints to the arm. + +Streaming a trajectory means the caller, not the servo, is responsible for +noticing that the arm has stopped keeping up. The measure is *lag*: how far the +arm trails the setpoint it was last given. Sustained lag means it is driving +against something it is not overcoming, and the move should stop where it is +rather than hold against the load. + +ROS-free, so the rule can be unit-tested without hardware. Used by +``arm_pose go`` and by episode replay. +""" + +from __future__ import annotations + +from collections.abc import Mapping + + +def lag_of(setpoint: Mapping[str, float], measured: Mapping[str, float]) -> float: + """Largest per-joint distance between a setpoint and where the arm is. + + Joints absent from ``measured`` contribute no lag: an unread joint is a + reporting gap, not evidence of a stall. + """ + return max( + ( + abs(measured[name] - value) + for name, value in setpoint.items() + if name in measured + ), + default=0.0, + ) + + +class LagSupervisor: + """Stops a streamed move once the arm has trailed its setpoint for too long. + + A single late sample is normal — the arm is always a little behind a moving + setpoint. Only lag that *stays* above ``max_lag`` for ``stall_time`` counts. + """ + + def __init__(self, max_lag: float = 0.15, stall_time: float = 1.5): + if max_lag <= 0: + raise ValueError("max_lag must be positive") + if stall_time <= 0: + raise ValueError("stall_time must be positive") + self.max_lag = max_lag + self.stall_time = stall_time + self.lagging_for = 0.0 + + def update(self, lag: float, dt: float) -> bool: + """Feed one observation; returns False once the move should stop.""" + if lag > self.max_lag: + self.lagging_for += dt + else: + self.lagging_for = 0.0 + return self.lagging_for < self.stall_time + + +def advance(current: float, target: float, max_step: float) -> float: + """Move ``current`` towards ``target`` by at most ``max_step``.""" + if max_step < 0: + raise ValueError("max_step must not be negative") + delta = target - current + if abs(delta) <= max_step: + return target + return current + (max_step if delta > 0 else -max_step) diff --git a/mote_arm/mote_arm/teleop.py b/mote_arm/mote_arm/teleop.py new file mode 100644 index 0000000..f5119d6 --- /dev/null +++ b/mote_arm/mote_arm/teleop.py @@ -0,0 +1,174 @@ +"""The virtual leader's follow rule, with no ROS and no hardware attached. + +Teleoperation here is leader-follower without a leader arm: a *virtual leader* +is a pose held in software that an operator moves, and the follower mirrors it. +This module is the mirroring itself — everything that decides whether the real +arm may move, and how far, in one place that a unit test can drive: + + * **clamping** — a leader pose outside the joint's soft limits is clamped + before it ever becomes a goal (the driver clamps again; this one exists so + the operator sees the limit rather than discovering it downstream), + * **rate limiting** — the commanded pose advances towards the leader by at + most ``max_velocity * dt``, so a leader that jumps (a slider dragged, a + frontend restarted at a different pose) produces a ramp, never a lunge, + * **the deadman** — the leader's *liveness* is the deadman. A frontend + publishes only while the operator is actually driving it, so input that + stops — a released key, a closed window, an SSH session dropped mid-move — + all arrive as the same thing: no fresh leader pose. Motion then halts, + * **the panic latch** — an engaged e-stop suppresses every goal until it is + explicitly cleared, so torque coming back on cannot restart the move. + +Resuming after a hold re-seeds the commanded pose from where the arm *actually* +is. Without that, a pause would bank up the difference and pay it out as a jump +on resume. +""" + +from __future__ import annotations + +from collections.abc import Mapping, Sequence +from dataclasses import dataclass + +from mote_arm.config import JointSpec +from mote_arm.motion import advance + + +@dataclass(frozen=True) +class MirrorLimits: + """How fast the follower may chase the leader, and when it stops trying.""" + + # Radians per second the commanded pose may advance. Deliberately at or + # above the virtual leader's own speed, so the follower is never left with + # a backlog of leader motion to work through after the operator stops. + max_velocity: float = 0.5 + # Seconds without a leader pose before motion halts. Long enough to cover a + # terminal's key-repeat gap, short enough that a released key stops the arm + # while it is still obviously connected to the key. + deadman_timeout: float = 0.4 + + def __post_init__(self) -> None: + if self.max_velocity <= 0: + raise ValueError("max_velocity must be positive") + if self.deadman_timeout <= 0: + raise ValueError("deadman_timeout must be positive") + + +# What the mirror is doing, for logging and for tests to assert on. +TRACKING = "tracking" +HOLDING = "holding" # deadman: no fresh leader pose +ESTOPPED = "estopped" +WAITING = "waiting" # no follower state yet, so nothing is safe to command + + +class LeaderMirror: + """Turns virtual-leader poses into rate-limited, clamped follower goals.""" + + def __init__( + self, + joints: Sequence[JointSpec], + limits: MirrorLimits | None = None, + ): + self._joints = {j.name: j for j in joints} + self.limits = limits or MirrorLimits() + self._measured: dict[str, float] = {} + self._leader: dict[str, float] = {} + self._leader_stamp: float | None = None + self._commanded: dict[str, float] = {} + self._estop = False + # True when the commanded pose is not trustworthy as a starting point — + # at startup, and after any hold — so the next goal re-seeds from the + # follower's measured pose instead of resuming from a stale command. + self._reseed = True + # Set when a hold begins, so the mirror can issue one goal at the + # arm's present position: that halts the residual travel towards the + # last setpoint instead of letting it coast there. + self._halt_pending = False + self.state = WAITING + + @property + def estopped(self) -> bool: + return self._estop + + def on_leader(self, pose: Mapping[str, float], now: float) -> None: + """Record a virtual-leader pose. Unknown joint names are ignored.""" + self._leader = {n: v for n, v in pose.items() if n in self._joints} + if self._leader: + self._leader_stamp = now + + def on_measured(self, pose: Mapping[str, float]) -> None: + """Record where the follower actually is.""" + for name, value in pose.items(): + if name in self._joints: + self._measured[name] = value + + def set_estop(self, engaged: bool, now: float) -> None: + """Engage or clear the panic latch. + + Clearing does not resume motion by itself: the commanded pose is marked + for re-seeding, so the arm picks up from where it is rather than from + where it was heading when the operator hit the panic key. + """ + if engaged and not self._estop: + self._commanded = {} + if not engaged and self._estop: + self._reseed = True + self._estop = engaged + + def update(self, now: float, dt: float) -> dict[str, float] | None: + """Advance one tick; returns the goal to publish, or None to send nothing. + + Sending nothing is how the arm stops: the driver holds its last goal, so + an absent command is a hold, not a drift. + """ + if self._estop: + self.state = ESTOPPED + return None + if not self._measured: + self.state = WAITING + return None + + stale = ( + self._leader_stamp is None + or (now - self._leader_stamp) > self.limits.deadman_timeout + ) + if stale: + if self.state == TRACKING: + self._halt_pending = True + self._reseed = True + self.state = HOLDING + if self._halt_pending: + self._halt_pending = False + # One goal at the present position: stop here, don't coast on + # to the setpoint the arm was still travelling towards. + return dict(self._measured) + return None + + self._halt_pending = False + if self._reseed or not self._commanded: + self._commanded = dict(self._measured) + self._reseed = False + + max_step = self.limits.max_velocity * max(0.0, dt) + goal: dict[str, float] = {} + for name, target in self._leader.items(): + joint = self._joints[name] + start = self._commanded.get(name, self._measured.get(name)) + if start is None: + continue + stepped = advance(start, joint.clamp_rad(target), max_step) + self._commanded[name] = stepped + goal[name] = stepped + + self.state = TRACKING + return goal or None + + +def sync_pose( + measured: Mapping[str, float], joints: Sequence[JointSpec] +) -> dict[str, float]: + """The virtual leader's pose when it re-syncs to the arm: measured, clamped. + + A leader re-synced to a follower sitting fractionally outside its soft band + (limits are taught, and a servo droops) would otherwise hand back a pose the + mirror immediately clamps, showing a leader that cannot be where it says. + """ + return {j.name: j.clamp_rad(measured[j.name]) for j in joints if j.name in measured} diff --git a/mote_arm/mote_arm/virtual_leader.py b/mote_arm/mote_arm/virtual_leader.py new file mode 100644 index 0000000..b8d3794 --- /dev/null +++ b/mote_arm/mote_arm/virtual_leader.py @@ -0,0 +1,315 @@ +"""The virtual leader: a leader arm that exists only in software. + +Leader-follower teleoperation normally needs two arms — an operator moves the +leader and the follower mirrors it. We have one arm. So the leader is a pose +held in this process, moved by the keyboard, published on ``leader/joint_states`` +for ``arm_mirror`` to stream to the follower (and for RViz to draw, if you want +to watch it). + +Nothing here talks to the servo bus, or even to the driver: it publishes a pose +and an e-stop flag, and that is the whole interface. Any other frontend that can +publish ``leader/joint_states`` is a drop-in replacement — a slider GUI, a +gamepad, a script — which is why the leader and the mirror are separate nodes. + + hold q/a w/s e/d r/f t/g y/h move joint 1..6 up/down + tap 0 re-sync the leader to where the arm is + tap SPACE PANIC: torque off, latched + tap z clear the panic latch + tap [ ] slower / faster + tap ? help x quit + +**The deadman is key repeat.** A held key auto-repeats; the leader moves only +while those repeats keep arriving and stops within ``--key-timeout`` of the last +one. Release the key and the leader stops publishing, which is what the mirror +reads as "the operator let go". A single tap therefore produces a short, bounded +move (``key_timeout * speed`` radians) rather than nothing — that is the terminal's +key-repeat behaviour showing through, not a debounce we could tune away without +losing the ability to run this over SSH. + +Whenever it goes idle the leader re-syncs to the follower's measured pose, so it +can never bank up a lead the arm has to chase after the operator has stopped. +""" + +from __future__ import annotations + +import argparse +import select +import sys +import termios +import threading +import time +import tty + +import rclpy +from rclpy.node import Node +from sensor_msgs.msg import JointState +from std_msgs.msg import Bool + +from mote_arm import cli, config, teleop +from mote_arm.mirror import latched + +# Key pairs in joint order: the top row raises a joint, the home row lowers it. +KEY_PAIRS = [("q", "a"), ("w", "s"), ("e", "d"), ("r", "f"), ("t", "g"), ("y", "h")] + +PANIC_KEY = " " +CLEAR_KEY = "z" +SYNC_KEY = "0" +QUIT_KEYS = ("x", "\x03", "\x04") +PUBLISH_RATE_HZ = 20.0 + + +class VirtualLeader(Node): + def __init__(self, speed: float, key_timeout: float): + super().__init__("virtual_leader") + self.declare_parameter("robot_yaml", "") + path = self.get_parameter("robot_yaml").get_parameter_value().string_value + self.cfg = config.ArmConfig.from_yaml_file(path) if path else config.load() + + self.speed = speed + self.key_timeout = key_timeout + self._lock = threading.Lock() + self._measured: dict[str, float] = {} + self.pose: dict[str, float] = {} + # Per joint: which way it is being driven, and when its key last repeated. + self._direction: dict[str, float] = {} + self._key_time: dict[str, float] = {} + + self._pub = self.create_publisher(JointState, "leader/joint_states", 10) + self._estop_pub = self.create_publisher(Bool, "teleop/estop", latched()) + self.create_subscription(JointState, "joint_states", self._on_states, 10) + + self.keys: dict[str, tuple[str, float]] = {} + for pair, joint in zip(KEY_PAIRS, self.cfg.joints): + self.keys[pair[0]] = (joint.name, +1.0) + self.keys[pair[1]] = (joint.name, -1.0) + + def _on_states(self, msg: JointState) -> None: + with self._lock: + for name, position in zip(msg.name, msg.position): + self._measured[name] = position + + def measured(self) -> dict[str, float]: + with self._lock: + return dict(self._measured) + + def wait_for_states(self, timeout: float = 5.0) -> bool: + deadline = time.time() + timeout + while time.time() < deadline: + if len(self.measured()) >= len(self.cfg.names): + return True + time.sleep(0.05) + return False + + def sync(self) -> None: + """Put the leader exactly where the arm is.""" + self.pose = teleop.sync_pose(self.measured(), self.cfg.joints) + + def press(self, name: str, direction: float, now: float) -> None: + self._direction[name] = direction + self._key_time[name] = now + + def step(self, now: float, dt: float) -> bool: + """Advance the leader pose; True if it is live (an input is being held).""" + live = False + for name, last in list(self._key_time.items()): + if now - last > self.key_timeout: + continue + live = True + joint = self.cfg.joint(name) + current = self.pose.get(name, self.measured().get(name, 0.0)) + self.pose[name] = joint.clamp_rad( + current + self._direction[name] * self.speed * dt + ) + return live + + def publish(self) -> None: + msg = JointState() + msg.header.stamp = self.get_clock().now().to_msg() + msg.name = [j.name for j in self.cfg.joints if j.name in self.pose] + msg.position = [self.pose[n] for n in msg.name] + self._pub.publish(msg) + + def set_estop(self, engaged: bool) -> None: + self._estop_pub.publish(Bool(data=engaged)) + + +def _out(text: str = "") -> None: + """Print in a raw terminal, where a bare newline would stair-step.""" + sys.stdout.write(text + "\r\n") + sys.stdout.flush() + + +def _help(node: VirtualLeader) -> None: + _out() + _out(f"speed {node.speed:.2f} rad/s deadman {node.key_timeout:.2f} s") + for pair, joint in zip(KEY_PAIRS, node.cfg.joints): + _out( + f" {pair[0]} / {pair[1]} {joint.name:<14} " + f"limits [{joint.min_rad:+.3f}, {joint.max_rad:+.3f}]" + ) + _out(" SPACE panic (torque off) z clear 0 re-sync [ ] speed x quit") + + +def _status(node: VirtualLeader, estopped: bool) -> None: + measured = node.measured() + state = "PANIC" if estopped else "ready" + parts = " ".join( + f"{j.name.split('_')[0]}={measured.get(j.name, float('nan')):+.3f}" + for j in node.cfg.joints + ) + _out(f"[{state}] {parts}") + + +def _drive(node: VirtualLeader) -> None: + period = 1.0 / PUBLISH_RATE_HZ + estopped = False + idle_since = time.monotonic() + + _out("virtual leader — the arm mirrors this pose. '?' for keys, 'x' to quit.") + if not node.wait_for_states(): + _out("warning: no /joint_states — is `pixi run arm` running?") + node.sync() + _help(node) + + while True: + now = time.monotonic() + # Drain every key waiting: a held key auto-repeats faster than we tick, + # and the useful signal is *that* it repeated, not how many times. + while select.select([sys.stdin], [], [], 0)[0]: + key = sys.stdin.read(1) + if key in QUIT_KEYS: + return + if key in node.keys: + name, direction = node.keys[key] + node.press(name, direction, now) + elif key == PANIC_KEY: + estopped = True + node.set_estop(True) + node.sync() + _out("PANIC — torque off and latched. 'z' to clear.") + elif key == CLEAR_KEY: + if estopped: + estopped = False + node.sync() + node.set_estop(False) + _out("panic cleared — the arm will follow again.") + elif key == SYNC_KEY: + node.sync() + _out("leader re-synced to the arm's pose") + elif key == "[": + node.speed = max(0.05, node.speed - 0.05) + _out(f"speed {node.speed:.2f} rad/s") + elif key == "]": + node.speed = min(1.0, node.speed + 0.05) + _out(f"speed {node.speed:.2f} rad/s") + elif key in ("?", "h"): + _help(node) + elif key == "p": + _status(node, estopped) + + live = node.step(now, period) and not estopped + if live: + node.publish() + idle_since = now + elif now - idle_since > node.key_timeout: + # Idle: the leader must not sit ahead of the arm, or resuming would + # pay out the accumulated difference as an unrequested move. + node.sync() + idle_since = now + + time.sleep(period) + + +def _demo(node: VirtualLeader, seconds: float) -> None: + """Drive a canned sweep with no terminal, for tests and unattended checks. + + It presses the same keys the operator would, through the same code path, so + what it exercises is the real leader — including a deliberate pause in the + middle, which is the deadman doing its job rather than a gap in the script. + """ + period = 1.0 / PUBLISH_RATE_HZ + joint = node.cfg.joints[0].name + _out(f"demo: sweeping {joint} for {seconds:.0f}s (no terminal)") + if not node.wait_for_states(): + raise SystemExit( + "no /joint_states — is the arm (or `pixi run arm-mock`) running?" + ) + node.sync() + + start = time.monotonic() + while True: + now = time.monotonic() + elapsed = now - start + if elapsed >= seconds: + break + phase = (elapsed % (seconds / 2)) / (seconds / 2) + # Middle fifth of each half: no key pressed, so the mirror's deadman + # holds the arm. A demo that never lets go would not prove it stops. + if not 0.4 <= phase < 0.6: + node.press(joint, +1.0 if elapsed < seconds / 2 else -1.0, now) + if node.step(now, period): + node.publish() + time.sleep(period) + _out("demo finished") + + +def main() -> None: + parser = argparse.ArgumentParser( + description="Keyboard virtual leader for the SO-101" + ) + parser.add_argument( + "--speed", + type=float, + default=0.25, + help="radians per second the leader moves while a key is held (default 0.25)", + ) + parser.add_argument( + "--key-timeout", + type=float, + default=0.35, + help="seconds after the last key repeat before the leader stops (default 0.35)", + ) + parser.add_argument( + "--demo", + type=float, + default=None, + metavar="SECONDS", + help="sweep the first joint for N seconds without a terminal (tests, checks)", + ) + args = cli.parse(parser) + + rclpy.init() + node = VirtualLeader(args.speed, args.key_timeout) + + spinner = cli.spin_background(node) + + try: + if args.demo is not None: + _demo(node, args.demo) + else: + _interactive(node) + except KeyboardInterrupt: + pass + finally: + print("\nvirtual leader stopped; the arm holds where it is.") + cli.shutdown(node, spinner) + + +def _interactive(node: VirtualLeader) -> None: + """Run the keyboard loop with the terminal in cbreak mode, and restore it.""" + if not sys.stdin.isatty(): + raise SystemExit( + "the virtual leader needs a terminal (it reads held keys) — run it " + "with `pixi run arm-teleop`, not from a launch file. For an " + "unattended sweep, use --demo SECONDS." + ) + settings = termios.tcgetattr(sys.stdin) + try: + tty.setcbreak(sys.stdin.fileno()) + _drive(node) + finally: + termios.tcsetattr(sys.stdin, termios.TCSADRAIN, settings) + + +if __name__ == "__main__": + main() diff --git a/mote_arm/package.xml b/mote_arm/package.xml index 527fd6a..15c07fe 100644 --- a/mote_arm/package.xml +++ b/mote_arm/package.xml @@ -10,11 +10,13 @@ rclpy mote_description sensor_msgs - + trajectory_msgs controller_manager_msgs + + std_msgs python3-yaml diff --git a/mote_arm/setup.py b/mote_arm/setup.py index b55f1d4..aa8cb35 100644 --- a/mote_arm/setup.py +++ b/mote_arm/setup.py @@ -26,6 +26,11 @@ "arm_offsets = mote_arm.arm_offsets:main", "arm_pose = mote_arm.arm_pose:main", "arm_gains = mote_arm.arm_gains:main", + "virtual_leader = mote_arm.virtual_leader:main", + "arm_mirror = mote_arm.mirror:main", + "mock_arm = mote_arm.mock_arm:main", + "episode_record = mote_arm.episode_record:main", + "episode_replay = mote_arm.episode_replay:main", ], }, ) diff --git a/mote_arm/test/teleop_loop/check_capture.py b/mote_arm/test/teleop_loop/check_capture.py new file mode 100755 index 0000000..a8a50a5 --- /dev/null +++ b/mote_arm/test/teleop_loop/check_capture.py @@ -0,0 +1,87 @@ +#!/usr/bin/env python3 +"""Assert that a capture holds a real teleoperated motion, not just rows. + +A recorder that ran but recorded nothing useful — a frozen arm, missing images, +actions that never differ from the state — still produces a well-formed capture. +These are the checks that tell the difference, so ``run_teleop_loop.sh`` fails +loudly instead of passing on an empty dataset. +""" + +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[2])) + +from mote_arm.episode import list_episodes, load_dataset_spec, load_episode, resample + + +def main() -> int: + capture = Path(sys.argv[1]) + spec = load_dataset_spec(capture) + episodes = list_episodes(capture) + if not episodes: + print("no episodes recorded", file=sys.stderr) + return 1 + + problems = [] + for path in episodes: + episode = load_episode(path) + frames = episode.frames + name = path.name + if len(frames) < 20: + problems.append(f"{name}: only {len(frames)} frames") + continue + + span = [ + max(f.state[i] for f in frames) - min(f.state[i] for f in frames) + for i in range(len(spec.joints)) + ] + if max(span) < 0.02: + problems.append( + f"{name}: the arm never moved (widest joint span {max(span):.4f} rad)" + ) + + # The action is what reached arm_controller. If it never leads the + # state, nothing was commanded and the episode records a coincidence. + lead = max( + abs(f.action[i] - f.state[i]) + for f in frames + for i in range(len(spec.joints)) + ) + if lead < 1e-6: + problems.append( + f"{name}: action never differs from state — nothing was commanded" + ) + + if spec.camera is not None: + missing = [ + f for f in frames if not f.image or not (path / f.image).exists() + ] + if missing: + problems.append( + f"{name}: {len(missing)}/{len(frames)} frames have no image" + ) + sizes = {(path / f.image).stat().st_size for f in frames if f.image} + if len(sizes) < 2: + problems.append(f"{name}: every camera frame is byte-identical") + + gridded = resample(frames, spec.fps) + drift = abs(len(gridded) - len(frames)) + if drift > max(2, 0.05 * len(frames)): + problems.append( + f"{name}: recorded {len(frames)} frames but the timeline implies " + f"{len(gridded)} at {spec.fps} fps — the recorder is not keeping its rate" + ) + + print( + f" {name}: {len(frames)} frames, {episode.duration:.1f}s, " + f"widest joint span {max(span):.3f} rad, task {episode.task!r}" + ) + + for problem in problems: + print(f" PROBLEM {problem}", file=sys.stderr) + return 1 if problems else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/mote_arm/test/teleop_loop/run_teleop_loop.sh b/mote_arm/test/teleop_loop/run_teleop_loop.sh new file mode 100755 index 0000000..d97de58 --- /dev/null +++ b/mote_arm/test/teleop_loop/run_teleop_loop.sh @@ -0,0 +1,105 @@ +#!/usr/bin/env bash +# The whole teleop loop against a follower that isn't there. +# +# mock_arm (+ synthetic camera) -> arm_mirror -> virtual_leader --demo +# -> episode_record -> episode_replay +# +# Nothing here needs the arm, the camera, or a terminal, so it is the gate to +# run before taking any of this to the bench (mote_arm/BENCH.md). It ends by +# planning the LeRobot export, which needs no LeRobot: the plan is computed from +# the capture alone. +# +# pixi run arm-teleop-test [seconds] +set -euo pipefail + +DEMO_SECONDS="${1:-12}" +WORK="$(mktemp -d "${TMPDIR:-/tmp}/mote-teleop-loop.XXXXXX")" +CAPTURE="$WORK/episodes/loop" +LOGS="$WORK/logs" +mkdir -p "$LOGS" + +# Off the LAN and off any sibling session: these nodes command arm_controller, +# which moves a real arm. Same rule as the rclpy unit tests. +export ROS_DOMAIN_ID=$((RANDOM % 40 + 60)) +export ROS_AUTOMATIC_DISCOVERY_RANGE=LOCALHOST + +PIDS=() +cleanup() { + for pid in "${PIDS[@]:-}"; do + kill "$pid" 2>/dev/null || true + done + wait 2>/dev/null || true +} +trap cleanup EXIT + +fail() { + echo "FAIL: $*" >&2 + echo "--- logs in $LOGS ---" >&2 + tail -n 20 "$LOGS"/*.log >&2 || true + exit 1 +} + +declare -A PID_OF +background() { + local name="$1" + shift + "$@" >"$LOGS/$name.log" 2>&1 & + PID_OF[$name]=$! + PIDS+=("$!") +} + +stop() { + for name in "$@"; do + kill "${PID_OF[$name]}" 2>/dev/null || true + wait "${PID_OF[$name]}" 2>/dev/null || true + done +} + +echo "== 1/6 mock follower with a synthetic camera ==" +# --droop: a real servo settles short of its goal (kp*error balances the load), +# so the mock does too. Without it the mock lands exactly on every setpoint and +# the recorded action would be indistinguishable from the observed state. +background mock_arm ros2 run mote_arm mock_arm --camera --rate 20 --speed 1.0 --droop 0.01 +background mirror ros2 run mote_arm arm_mirror +sleep 4 + +echo "== 2/6 teleop: virtual leader -> mirror -> follower, ${DEMO_SECONDS}s ==" +background leader ros2 run mote_arm virtual_leader -- --demo "$DEMO_SECONDS" --speed 0.3 + +echo "== 3/6 record the session ==" +ros2 run mote_arm episode_record -- \ + --task "sweep the first joint" \ + --root "$CAPTURE" \ + --duration "$((DEMO_SECONDS - 3))" \ + --episodes 1 \ + >"$LOGS/record.log" 2>&1 || fail "recording exited non-zero" +cat "$LOGS/record.log" + +EPISODE="$CAPTURE/episode_000" +[ -f "$CAPTURE/dataset.json" ] || fail "no dataset.json in $CAPTURE" +[ -f "$EPISODE/episode.json" ] || fail "episode was not closed" + +echo "== 4/6 check the capture actually holds a motion ==" +python3 "$(dirname "$0")/check_capture.py" "$CAPTURE" || fail "capture check failed" + +echo "== 5/6 replay it on the follower at half speed ==" +# The leader and the mirror have to be out of the way first: replay publishes +# arm_controller itself, and two things commanding one arm fight. (The stall +# guard does catch it — that is how this was found — but a caught stall is not +# a passing replay.) +stop leader mirror +sleep 1 +ros2 run mote_arm episode_replay -- "$CAPTURE" --episode 0 --yes --speed-scale 0.5 \ + >"$LOGS/replay.log" 2>&1 || fail "replay exited non-zero" +tail -n 12 "$LOGS/replay.log" +grep -q "STOPPED during" "$LOGS/replay.log" && fail "replay stalled" + +echo "== 6/6 plan the LeRobot export (no LeRobot required) ==" +python3 "$(dirname "$0")/../../tools/lerobot_export.py" --capture "$CAPTURE" --dry-run \ + || fail "export plan failed" + +echo +echo "PASS — teleop, recording, replay and the export plan all ran headless." +echo "capture kept at: $CAPTURE" +trap - EXIT +cleanup diff --git a/mote_arm/test/test_episode.py b/mote_arm/test/test_episode.py new file mode 100644 index 0000000..d7d7713 --- /dev/null +++ b/mote_arm/test/test_episode.py @@ -0,0 +1,145 @@ +"""The capture format: what the robot writes, and what replay and export read.""" + +import json + +import pytest + +from mote_arm.episode import ( + CameraSpec, + DatasetSpec, + EpisodeWriter, + Frame, + episodes_root, + list_episodes, + load_dataset_spec, + load_episode, + next_episode_index, + resample, +) + +JOINTS = ("shoulder_pan", "elbow_flex") + + +def spec(camera: bool = True) -> DatasetSpec: + return DatasetSpec( + name="teleop", + fps=20, + joints=JOINTS, + camera=CameraSpec(key="front", topic="/image_raw/compressed") + if camera + else None, + ) + + +def test_capture_round_trips(tmp_path): + writer = EpisodeWriter(tmp_path, spec(), task="pick up the block") + writer.add(100.0, [0.1, 0.2], [0.15, 0.25], image=b"\x89PNG-not-really") + writer.add(100.05, [0.11, 0.21], [0.15, 0.25], image=b"second") + path = writer.close() + + assert load_dataset_spec(tmp_path) == spec() + episode = load_episode(path) + assert episode.task == "pick up the block" + assert episode.index == 0 + assert [f.t for f in episode.frames] == pytest.approx([0.0, 0.05]) + assert episode.frames[0].state == pytest.approx((0.1, 0.2)) + assert episode.frames[1].action == pytest.approx((0.15, 0.25)) + assert episode.image_path(episode.frames[1]).read_bytes() == b"second" + + +def test_timestamps_are_relative_to_the_first_frame(tmp_path): + # The recorder samples on a monotonic clock, which starts wherever the + # machine booted; an episode's timeline has to start at zero. + writer = EpisodeWriter(tmp_path, spec(camera=False), task="t") + writer.add(98765.5, [0.0, 0.0], [0.0, 0.0]) + writer.add(98766.0, [0.0, 0.0], [0.0, 0.0]) + episode = load_episode(writer.close()) + assert [f.t for f in episode.frames] == pytest.approx([0.0, 0.5]) + assert episode.duration == pytest.approx(0.5) + + +def test_episodes_accumulate_and_indices_are_never_reused(tmp_path): + for _ in range(3): + writer = EpisodeWriter(tmp_path, spec(camera=False), task="t") + writer.add(0.0, [0.0, 0.0], [0.0, 0.0]) + writer.close() + assert [p.name for p in list_episodes(tmp_path)] == [ + "episode_000", + "episode_001", + "episode_002", + ] + + discarded = EpisodeWriter(tmp_path, spec(camera=False), task="t") + discarded.add(0.0, [0.0, 0.0], [0.0, 0.0]) + discarded.discard() + assert not discarded.path.exists() + # The gap stays a gap: a discarded episode 3 must not be re-issued to a + # later recording, or a number in someone's notes would name two takes. + assert next_episode_index(tmp_path) == 3 + + +def test_a_killed_recorder_leaves_readable_frames(tmp_path): + writer = EpisodeWriter(tmp_path, spec(camera=False), task="t") + writer.add(0.0, [0.1, 0.1], [0.1, 0.1]) + writer.add(0.05, [0.2, 0.2], [0.2, 0.2]) + # Rows are flushed as they are written, so a kill mid-write truncates the + # last line and nothing else. No episode.json is ever written. + with (writer.path / "frames.jsonl").open("a") as handle: + handle.write('{"t": 0.1, "state": [0.3, 0.') + + episode = load_episode(writer.path) + assert len(episode.frames) == 2 + assert episode.task == "" + + +def test_an_image_needs_a_camera_in_the_spec(tmp_path): + writer = EpisodeWriter(tmp_path, spec(camera=False), task="t") + with pytest.raises(ValueError, match="no camera"): + writer.add(0.0, [0.0, 0.0], [0.0, 0.0], image=b"x") + + +def test_an_episode_needs_a_task(tmp_path): + with pytest.raises(ValueError, match="task"): + EpisodeWriter(tmp_path, spec(), task="") + + +def test_a_capture_from_another_format_version_is_refused(tmp_path): + (tmp_path / "dataset.json").write_text( + json.dumps({**spec().to_dict(), "version": 99}) + ) + with pytest.raises(ValueError, match="version 99"): + load_dataset_spec(tmp_path) + + +def test_episodes_root_follows_mote_home(tmp_path, monkeypatch): + monkeypatch.setenv("MOTE_HOME", str(tmp_path)) + assert episodes_root() == tmp_path / "episodes" + + +def frames(*times) -> list[Frame]: + return [Frame(t=t, state=(t,), action=(t,)) for t in times] + + +def test_resample_is_a_no_op_on_an_exact_grid(): + original = frames(0.0, 0.1, 0.2) + assert resample(original, fps=10) == original + + +def test_resample_holds_the_most_recent_sample(): + # LeRobot stores no timestamps — it derives them from the index and fps — so + # a slipped capture would otherwise export as if its timing had been perfect. + out = resample(frames(0.0, 0.07, 0.23), fps=10) + assert [f.t for f in out] == pytest.approx([0.0, 0.1, 0.2]) + # Zero-order hold, never interpolation and never a peek ahead: 0.1 s takes + # the 0.07 s sample, and 0.2 s still does, because 0.23 has not happened yet. + assert [f.state[0] for f in out] == pytest.approx([0.0, 0.07, 0.07]) + + +def test_resample_of_an_empty_or_single_frame_episode(): + assert resample([], fps=20) == [] + assert len(resample(frames(0.0), fps=20)) == 1 + + +def test_resample_rejects_a_nonsense_rate(): + with pytest.raises(ValueError): + resample(frames(0.0, 0.1), fps=0) diff --git a/mote_arm/test/test_motion.py b/mote_arm/test/test_motion.py new file mode 100644 index 0000000..dc856c6 --- /dev/null +++ b/mote_arm/test/test_motion.py @@ -0,0 +1,53 @@ +"""Streaming supervision: the rule that stops a move the arm is not completing.""" + +import pytest + +from mote_arm.motion import LagSupervisor, advance, lag_of + + +def test_lag_is_the_worst_joint(): + setpoint = {"a": 1.0, "b": 2.0} + assert lag_of(setpoint, {"a": 0.9, "b": 1.5}) == pytest.approx(0.5) + + +def test_unread_joints_do_not_count_as_lag(): + # A joint missing from /joint_states is a reporting gap, not a stall; the + # opposite reading would stop every move the instant a read was dropped. + assert lag_of({"a": 1.0, "b": 2.0}, {"a": 1.0}) == pytest.approx(0.0) + assert lag_of({"a": 1.0}, {}) == pytest.approx(0.0) + + +def test_brief_lag_does_not_stop_the_move(): + supervisor = LagSupervisor(max_lag=0.1, stall_time=1.0) + for _ in range(3): + assert supervisor.update(0.5, 0.25) is True + # Catching up resets the clock: the arm is keeping up again, so the next + # three lagging ticks are as harmless as the first three. + assert supervisor.update(0.0, 0.25) is True + for _ in range(3): + assert supervisor.update(0.5, 0.25) is True + + +def test_sustained_lag_stops_the_move(): + supervisor = LagSupervisor(max_lag=0.1, stall_time=1.0) + for _ in range(3): + assert supervisor.update(0.5, 0.25) is True + assert supervisor.update(0.5, 0.25) is False + + +def test_supervisor_rejects_nonsense_thresholds(): + with pytest.raises(ValueError): + LagSupervisor(max_lag=0.0) + with pytest.raises(ValueError): + LagSupervisor(stall_time=-1.0) + + +def test_advance_never_overshoots(): + assert advance(0.0, 1.0, 0.25) == pytest.approx(0.25) + assert advance(0.0, 1.0, 5.0) == pytest.approx(1.0) + assert advance(1.0, 0.0, 0.25) == pytest.approx(0.75) + assert advance(1.0, 1.0, 0.25) == pytest.approx(1.0) + + +def test_advance_of_zero_step_holds(): + assert advance(0.5, 1.0, 0.0) == pytest.approx(0.5) diff --git a/mote_arm/test/test_teleop.py b/mote_arm/test/test_teleop.py new file mode 100644 index 0000000..47a536b --- /dev/null +++ b/mote_arm/test/test_teleop.py @@ -0,0 +1,157 @@ +"""The follow rule: clamping, rate limiting, the deadman, and the panic latch. + +Every safety property of virtual-leader teleop is decided in ``LeaderMirror``, +so it is all checked here — with no bus, no driver and no terminal. +""" + +import pytest + +from mote_arm.config import JointSpec +from mote_arm.teleop import ( + ESTOPPED, + HOLDING, + TRACKING, + WAITING, + LeaderMirror, + MirrorLimits, + sync_pose, +) + +JOINTS = ( + JointSpec(name="elbow_flex", id=3, min_rad=-1.0, max_rad=1.0), + JointSpec(name="wrist_roll", id=5, min_rad=-0.1, max_rad=0.1), +) +LIMITS = MirrorLimits(max_velocity=1.0, deadman_timeout=0.4) +DT = 0.05 + + +def mirror(**kwargs) -> LeaderMirror: + return LeaderMirror(JOINTS, MirrorLimits(**{**LIMITS.__dict__, **kwargs})) + + +def drive( + m: LeaderMirror, leader: dict, seconds: float, start: float = 0.0 +) -> dict | None: + """Feed a steady leader pose for ``seconds`` and return the last goal.""" + goal = None + ticks = int(round(seconds / DT)) + for i in range(ticks): + now = start + i * DT + m.on_leader(leader, now) + goal = m.update(now, DT) + return goal + + +def test_nothing_is_commanded_before_the_arm_reports(): + m = mirror() + m.on_leader({"elbow_flex": 0.5}, 0.0) + assert m.update(0.0, DT) is None + assert m.state == WAITING + + +def test_goal_advances_at_the_rate_limit(): + m = mirror(max_velocity=1.0) + m.on_measured({"elbow_flex": 0.0, "wrist_roll": 0.0}) + m.on_leader({"elbow_flex": 1.0}, 0.0) + # One tick of 50 ms at 1 rad/s is 0.05 rad, however far away the leader is. + assert m.update(0.0, DT)["elbow_flex"] == pytest.approx(0.05) + assert m.update(DT, DT)["elbow_flex"] == pytest.approx(0.10) + + +def test_a_leader_jump_becomes_a_ramp_not_a_lunge(): + m = mirror(max_velocity=0.5) + m.on_measured({"elbow_flex": 0.0}) + # A slider dragged to the far end, or a frontend restarted at a different + # pose, is exactly this: one enormous step in the leader's position. + goal = drive(m, {"elbow_flex": 1.0}, seconds=0.2) + assert goal["elbow_flex"] == pytest.approx(0.1, abs=1e-9) + assert m.state == TRACKING + + +def test_goals_are_clamped_to_the_soft_limits(): + m = mirror() + m.on_measured({"wrist_roll": 0.0}) + goal = drive(m, {"wrist_roll": 5.0}, seconds=2.0) + assert goal["wrist_roll"] == pytest.approx(0.1) + + +def test_deadman_halts_at_the_arms_position_then_sends_nothing(): + m = mirror(deadman_timeout=0.2) + m.on_measured({"elbow_flex": 0.0}) + drive(m, {"elbow_flex": 1.0}, seconds=0.2) + m.on_measured({"elbow_flex": 0.15}) + + # First tick past the deadman: one goal at where the arm *is*, so it stops + # there instead of coasting on to the setpoint it was travelling towards. + halt = m.update(1.0, DT) + assert m.state == HOLDING + assert halt == pytest.approx({"elbow_flex": 0.15}) + # And then silence: an absent goal is a hold, because the driver keeps the + # last one it was given. + assert m.update(1.05, DT) is None + assert m.update(1.10, DT) is None + + +def test_resuming_starts_from_the_arm_not_from_the_stale_command(): + m = mirror(max_velocity=1.0, deadman_timeout=0.2) + m.on_measured({"elbow_flex": 0.0}) + drive(m, {"elbow_flex": 1.0}, seconds=0.5) # commanded is now ~0.5 + m.update(2.0, DT) # deadman + + # The arm settled short of the last command, as a real servo does. + m.on_measured({"elbow_flex": 0.42}) + resumed = drive(m, {"elbow_flex": 1.0}, seconds=DT, start=3.0) + # One tick past 0.42, not a jump back to the 0.5 it had banked up. + assert resumed["elbow_flex"] == pytest.approx(0.47) + + +def test_estop_suppresses_goals_and_latches(): + m = mirror() + m.on_measured({"elbow_flex": 0.0}) + drive(m, {"elbow_flex": 1.0}, seconds=0.2) + + m.set_estop(True, 1.0) + assert m.estopped + # Input keeps arriving — the whole point of a latch is that this changes + # nothing until someone clears it. + assert drive(m, {"elbow_flex": 1.0}, seconds=1.0, start=1.0) is None + assert m.state == ESTOPPED + + +def test_clearing_the_estop_resumes_from_the_arms_position(): + m = mirror(max_velocity=1.0) + m.on_measured({"elbow_flex": 0.0}) + drive(m, {"elbow_flex": 1.0}, seconds=0.5) + m.set_estop(True, 1.0) + m.on_measured({"elbow_flex": 0.3}) + m.set_estop(False, 2.0) + + resumed = drive(m, {"elbow_flex": 1.0}, seconds=DT, start=2.0) + assert resumed["elbow_flex"] == pytest.approx(0.35) + + +def test_unknown_leader_joints_are_ignored(): + m = mirror() + m.on_measured({"elbow_flex": 0.0}) + goal = drive(m, {"elbow_flex": 0.5, "not_a_joint": 9.9}, seconds=0.1) + assert set(goal) == {"elbow_flex"} + + +def test_a_leader_pose_of_only_unknown_joints_commands_nothing(): + m = mirror() + m.on_measured({"elbow_flex": 0.0}) + assert drive(m, {"gripper_of_another_robot": 1.0}, seconds=0.1) is None + + +def test_sync_pose_clamps_a_drooping_arm_into_the_band(): + # Limits are taught and servos droop, so the arm can sit fractionally + # outside its own band; a leader synced to that would show a pose the + # mirror immediately clamps. + assert sync_pose({"wrist_roll": 0.15}, JOINTS) == pytest.approx({"wrist_roll": 0.1}) + + +def test_limits_must_be_positive(): + with pytest.raises(ValueError): + MirrorLimits(max_velocity=0.0) + with pytest.raises(ValueError): + MirrorLimits(deadman_timeout=-1.0) diff --git a/mote_arm/test/test_teleop_node.py b/mote_arm/test/test_teleop_node.py new file mode 100644 index 0000000..0db4b85 --- /dev/null +++ b/mote_arm/test/test_teleop_node.py @@ -0,0 +1,188 @@ +"""Virtual-leader teleop end to end, against a control stack that isn't there. + +``mock_arm`` presents the interface ros2_control does — a trajectory topic and +``switch_controller`` — with no bus behind it, so the whole path (leader pose -> +mirror -> arm_controller -> arm) runs in one process and every safety behaviour +can be checked before anyone stands at the bench. + +The mirror is driven the way it is in production: the executor spins on a +worker thread and ``tick()`` is called from this one. That is not a test +convenience — taking hold of the arm is a ``switch_controller`` call, and a +service call made from inside an executor callback can never complete, because +the future is resolved by the executor the callback is blocking. + +A random ROS_DOMAIN_ID keeps these nodes off a live robot's graph (they command +``arm_controller``, which moves a real arm), and a per-process namespace keeps +them off sibling test sessions colcon runs in parallel. +""" + +import os +import random +import threading +import time +from argparse import Namespace + +os.environ["ROS_DOMAIN_ID"] = str(random.randint(60, 100)) + +import pytest # noqa: E402 +import rclpy # noqa: E402 +from rclpy.executors import SingleThreadedExecutor # noqa: E402 +from rclpy.node import Node # noqa: E402 +from sensor_msgs.msg import JointState # noqa: E402 +from std_msgs.msg import Bool # noqa: E402 + +from mote_arm import config, mirror as mirror_mod, mock_arm as mock_mod # noqa: E402 +from mote_arm.mirror import latched # noqa: E402 + +CFG = config.ArmConfig.from_dict( + { + "arm": { + "port": "/dev/fake", + "baud_rate": 1000000, + "joints": [ + {"name": "elbow_flex", "id": 3, "min": -1.0, "max": 1.0, "zero": 2048}, + {"name": "wrist_roll", "id": 5, "min": -0.1, "max": 0.1, "zero": 2048}, + ], + } + } +) + +MOCK_ARGS = Namespace( + rate=50.0, speed=2.0, droop=0.0, camera=False, camera_rate=10.0, camera_size=(8, 8) +) + + +class Leader(Node): + """Stands in for the keyboard frontend: publishes a leader pose on demand.""" + + def __init__(self): + super().__init__("leader_stub") + self._pub = self.create_publisher(JointState, "leader/joint_states", 10) + self._estop = self.create_publisher(Bool, "teleop/estop", latched()) + self.pose: dict[str, float] | None = None + self.create_timer(0.05, self._tick) + + def _tick(self) -> None: + if self.pose is None: + return + msg = JointState() + msg.header.stamp = self.get_clock().now().to_msg() + msg.name = list(self.pose) + msg.position = [self.pose[n] for n in msg.name] + self._pub.publish(msg) + + def panic(self, engaged: bool) -> None: + self._estop.publish(Bool(data=engaged)) + + +class Stack: + def __init__(self, mock, mirror, leader, executor): + self.mock = mock + self.mirror = mirror + self.leader = leader + self._executor = executor + + def run(self, seconds: float) -> None: + """Tick the mirror for a while, as its own main loop does.""" + deadline = time.monotonic() + seconds + while time.monotonic() < deadline: + self.mirror.tick() + time.sleep(self.mirror.period) + + def at(self, joint: str) -> float: + return self.mock.position[joint] + + +@pytest.fixture +def stack(monkeypatch): + monkeypatch.setattr(config, "load", lambda: CFG) + rclpy.init(args=["--ros-args", "-r", f"__ns:=/test_{os.getpid()}"]) + mock = mock_mod.MockArm(MOCK_ARGS) + mirror = mirror_mod.ArmMirror() + leader = Leader() + + executor = SingleThreadedExecutor() + for node in (mock, mirror, leader): + executor.add_node(node) + spinner = threading.Thread(target=executor.spin, daemon=True) + spinner.start() + + built = Stack(mock, mirror, leader, executor) + # Let the mock's first joint_states reach the mirror, which refuses to + # command an arm it has not heard from. + time.sleep(0.3) + yield built + + executor.shutdown() + spinner.join(timeout=2.0) + for node in (mock, mirror, leader): + node.destroy_node() + rclpy.shutdown() + + +def test_the_arm_follows_the_virtual_leader(stack): + start = stack.at("elbow_flex") + stack.leader.pose = {"elbow_flex": 0.6} + stack.run(0.6) + assert stack.at("elbow_flex") > start + 0.1 + + +def test_commanding_takes_hold_of_a_limp_arm(stack): + # The mock starts with arm_controller inactive, exactly as the real stack + # spawns it; the first command is what makes the hardware take hold. + assert stack.mock.holding is False + stack.leader.pose = {"elbow_flex": 0.4} + stack.run(0.3) + assert stack.mock.holding is True + + +def test_following_is_rate_limited_not_instant(stack): + # A leader that jumps must not become an arm that jumps: the default 0.5 + # rad/s over ~0.5 s is a few tenths of a radian, nowhere near the target. + stack.leader.pose = {"elbow_flex": 1.0} + stack.run(0.5) + assert stack.at("elbow_flex") < 0.45 + + +def test_releasing_the_input_halts_the_arm(stack): + stack.leader.pose = {"elbow_flex": 1.0} + stack.run(0.6) + stack.leader.pose = None # the operator let go + stack.run(0.6) + + halted = stack.at("elbow_flex") + stack.run(0.6) + assert stack.at("elbow_flex") == pytest.approx(halted, abs=1e-6) + + +def test_goals_are_clamped_to_the_soft_limits(stack): + stack.leader.pose = {"wrist_roll": 5.0} + stack.run(1.2) + assert stack.at("wrist_roll") == pytest.approx(0.1, abs=1e-3) + + +def test_panic_drops_torque_and_the_arm_stops_even_while_driven(stack): + stack.leader.pose = {"elbow_flex": 1.0} + stack.run(0.4) + stack.leader.panic(True) + stack.run(0.4) + # Torque is controller activation, so dropping it means deactivating. + assert stack.mock.holding is False + + # The leader keeps publishing throughout: the latch, not the absence of + # input, is what holds the arm. + stopped = stack.at("elbow_flex") + stack.run(0.6) + assert stack.at("elbow_flex") == pytest.approx(stopped, abs=1e-6) + + +def test_clearing_panic_lets_the_arm_move_again(stack): + stack.leader.pose = {"elbow_flex": 1.0} + stack.run(0.3) + stack.leader.panic(True) + stack.run(0.3) + stopped = stack.at("elbow_flex") + + stack.leader.panic(False) + stack.run(0.6) + assert stack.at("elbow_flex") > stopped + 0.05 diff --git a/mote_arm/tools/bench_teleop.sh b/mote_arm/tools/bench_teleop.sh new file mode 100755 index 0000000..535348b --- /dev/null +++ b/mote_arm/tools/bench_teleop.sh @@ -0,0 +1,131 @@ +#!/usr/bin/env bash +# Guided bench session for virtual-leader teleop: teleop -> record -> inspect +# -> replay, with the safety behaviours demonstrated on the way. +# +# This is the hardware counterpart of `pixi run arm-teleop-test`, which runs the +# same loop headless against the mock follower. Run that first — this script +# assumes the software already works and is here to check the *arm* does. +# +# Three terminals: +# A: pixi run arm mirror:=true driver + mirror +# B: pixi run arm-teleop the virtual leader (you drive this) +# C: bash mote_arm/tools/bench_teleop.sh <- this script +# +# It writes a report you can paste into the task; nothing is recorded as passing +# that you did not say you saw. +set -euo pipefail + +DATASET="${1:-bench}" +CAPTURE="${MOTE_HOME:-$HOME/.mote}/episodes/$DATASET" +REPORT="$CAPTURE/bench-report.txt" +HERE="$(cd "$(dirname "$0")" && pwd)" + +note() { printf '%s\n' "$*" | tee -a "$REPORT"; } +rule() { printf '\n== %s ==\n' "$*" | tee -a "$REPORT"; } + +ask() { + # ask "" -> records observed / NOT OBSERVED + local prompt="$1" reply + read -r -p " $prompt [y/N] " reply + if [[ "$reply" =~ ^[Yy] ]]; then + note " PASS $prompt" + else + note " FAIL $prompt" + FAILURES=$((FAILURES + 1)) + fi +} + +FAILURES=0 +mkdir -p "$CAPTURE" +: >"$REPORT" +note "mote_arm virtual-leader bench session" +note "date: $(date -Is)" +note "capture: $CAPTURE" + +rule "0. preconditions" +cat <<'EOF' +Before starting, confirm at the arm: + * it is powered, physically supported, and free to move through its band + * `pixi run arm-gains show` reports kp=32 (droop, not stall — see README) + * terminal A is running `pixi run arm mirror:=true` + * terminal B is running `pixi run arm-teleop` +EOF +read -r -p " ready? [y/N] " ready +[[ "$ready" =~ ^[Yy] ]] || { echo "aborted"; exit 1; } + +rule "1. the arm is reporting" +if timeout 10 ros2 topic echo --once /joint_states >/dev/null 2>&1; then + note " PASS /joint_states is publishing" +else + note " FAIL no /joint_states — is terminal A running?" + exit 1 +fi +if ros2 node list 2>/dev/null | grep -q arm_mirror; then + note " PASS arm_mirror is up" +else + note " FAIL arm_mirror is not running — start terminal A with mirror:=true" + exit 1 +fi + +rule "2. teleop, and the three safety behaviours" +cat <<'EOF' +In terminal B, with a hand ready to hit SPACE: + + a) hold one joint's key and watch the arm follow smoothly + b) keep holding past the joint's soft limit — it must stop at the limit + c) release the key mid-move — it must stop within a fraction of a second + d) press SPACE — the arm must go limp immediately (PANIC latches) + e) press z to clear, then drive again — it must follow from where it is +EOF +ask "(a) the arm followed the leader smoothly" +ask "(b) it stopped at the soft limit and went no further" +ask "(c) releasing the key halted it" +ask "(d) SPACE dropped torque and the arm went limp" +ask "(e) clearing the panic resumed following without a jump" + +rule "3. record an episode" +echo "Teleop a simple motion in terminal B while this records." +ros2 run mote_arm episode_record --task "${TASK:-move the arm through a simple motion}" \ + --dataset "$DATASET" --episodes 1 2>&1 | tee -a "$REPORT" + +rule "4. check the capture" +if python3 "$HERE/../test/teleop_loop/check_capture.py" "$CAPTURE" 2>&1 | tee -a "$REPORT"; then + note " PASS capture holds a real motion" +else + note " FAIL capture check" + FAILURES=$((FAILURES + 1)) +fi + +rule "5. export and inspect (off-board)" +cat <&1 | tee -a "$REPORT" + ask "the arm retraced the recorded motion" +else + note " SKIP replay (leader still running)" + FAILURES=$((FAILURES + 1)) +fi + +rule "result" +if [ "$FAILURES" -eq 0 ]; then + note " PASS — teleop, recording, inspection and replay all verified on hardware" +else + note " $FAILURES check(s) did not pass" +fi +note "" +note "report: $REPORT" +exit "$((FAILURES > 0))" diff --git a/mote_arm/tools/lerobot_export.py b/mote_arm/tools/lerobot_export.py new file mode 100644 index 0000000..da7f7d2 --- /dev/null +++ b/mote_arm/tools/lerobot_export.py @@ -0,0 +1,279 @@ +"""Turn on-robot captures into a LeRobot dataset. Runs off-board, not on the Pi. + +The robot records a capture (``mote_arm/episode.py``): JSON lines plus the +camera's compressed frames, written with nothing but the standard library. This +converts that into a real ``LeRobotDataset`` — parquet shards, MP4 video, the +metadata LeRobot's loaders and viewers expect. + +**It writes the dataset through LeRobot's own API rather than emitting the files +itself.** The format has already moved once (v2.1's file-per-episode became +v3.0's aggregated shards) and will move again; a hand-rolled writer would be a +second implementation of someone else's schema, silently wrong the first time it +changed. Using ``LeRobotDataset.create`` / ``add_frame`` / ``save_episode`` / +``finalize`` means "valid" is whatever the installed LeRobot says it is. + +That API brings torch, ffmpeg and the HuggingFace stack with it, which is +exactly what the Pi does not carry — hence its own pixi environment, the same +split ``mote_perception`` makes for GPU inference:: + + pixi run -e lerobot arm-export -- --capture ~/.mote/episodes/teleop \\ + --repo-id mote/teleop-demo + +Then inspect it with LeRobot's own tooling:: + + pixi run -e lerobot -- lerobot-dataset-viz --repo-id mote/teleop-demo \\ + --root --episode-index 0 + +``--dry-run`` needs none of that: it reports the schema and the resampled frame +counts using only the capture, which is how the conversion is checked on a +machine that has no LeRobot. +""" + +from __future__ import annotations + +import argparse +import sys +from pathlib import Path + +# The capture format is defined once, in the ROS package. The exporter is +# ROS-free and runs in an environment with no ROS at all, so it imports that one +# module by path rather than keeping a second copy of the layout in step. +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +from mote_arm.episode import ( # noqa: E402 + DatasetSpec, + Episode, + list_episodes, + load_dataset_spec, + load_episode, + resample, +) + +STATE_KEY = "observation.state" +ACTION_KEY = "action" + + +def features_for(spec: DatasetSpec, image_shape: tuple[int, int, int] | None) -> dict: + """The LeRobot feature schema for a capture. + + Joint names go in ``names`` so a dataset stays self-describing: which column + is the elbow is otherwise only recoverable from the robot.yaml of the day. + """ + joints = list(spec.joints) + features = { + STATE_KEY: {"dtype": "float32", "shape": (len(joints),), "names": joints}, + ACTION_KEY: {"dtype": "float32", "shape": (len(joints),), "names": joints}, + } + if spec.camera is not None and image_shape is not None: + features[f"observation.images.{spec.camera.key}"] = { + "dtype": "video", + "shape": image_shape, + "names": ["height", "width", "channels"], + } + return features + + +def _image_shape(episode: Episode) -> tuple[int, int, int] | None: + """Read one frame to learn the camera's resolution.""" + from PIL import Image + + for frame in episode.frames: + path = episode.image_path(frame) + if path is not None and path.exists(): + with Image.open(path) as img: + width, height = img.size + return (height, width, 3) + return None + + +def _load_rgb(path: Path): + import numpy as np + from PIL import Image + + with Image.open(path) as img: + return np.asarray(img.convert("RGB"), dtype=np.uint8) + + +def plan( + capture: Path, fps: int | None +) -> tuple[DatasetSpec, list[tuple[Path, int, int]]]: + """What the export will do: the spec, and each episode's raw/resampled counts.""" + spec = load_dataset_spec(capture) + if fps is not None: + spec = DatasetSpec( + name=spec.name, + fps=fps, + joints=spec.joints, + robot_type=spec.robot_type, + camera=spec.camera, + ) + rows = [] + for path in list_episodes(capture): + episode = load_episode(path) + rows.append( + (path, len(episode.frames), len(resample(episode.frames, spec.fps))) + ) + return spec, rows + + +def export(args) -> Path: + import numpy as np + from lerobot.datasets.lerobot_dataset import LeRobotDataset + + capture = Path(args.capture) + spec, planned = plan(capture, args.fps) + episode_paths = [path for path, _, _ in planned] + if not episode_paths: + raise SystemExit(f"no episodes in {capture}") + + image_shape = None + if spec.camera is not None: + image_shape = _image_shape(load_episode(episode_paths[0])) + if image_shape is None: + raise SystemExit( + f"{capture} declares camera {spec.camera.key!r} but holds no frames " + "— re-record, or export a state-only capture" + ) + + features = features_for(spec, image_shape) + image_key = f"observation.images.{spec.camera.key}" if image_shape else None + root = Path(args.output) if args.output else capture / "lerobot" + if root.exists() and any(root.iterdir()): + raise SystemExit( + f"{root} already exists and is not empty — pass --output elsewhere" + ) + + dataset = LeRobotDataset.create( + repo_id=args.repo_id, + fps=spec.fps, + features=features, + root=root, + robot_type=spec.robot_type, + use_videos=not args.images, + ) + try: + for path in episode_paths: + episode = load_episode(path) + frames = resample(episode.frames, spec.fps) + if not frames: + print(f"skipping {path.name}: no frames") + continue + task = episode.task or args.task + if not task: + raise SystemExit( + f"{path.name} has no task string — pass --task to supply one" + ) + for frame in frames: + row = { + STATE_KEY: np.asarray(frame.state, dtype=np.float32), + ACTION_KEY: np.asarray(frame.action, dtype=np.float32), + "task": task, + } + if image_key is not None: + image = episode.image_path(frame) + if image is None or not image.exists(): + raise SystemExit( + f"{path.name} frame at t={frame.t:.3f} has no image, but the " + "capture declares a camera" + ) + row[image_key] = _load_rgb(image) + dataset.add_frame(row) + dataset.save_episode() + print(f" {path.name}: {len(frames)} frames, task {task!r}") + finally: + # Without finalize the parquet footers are never written and the dataset + # will not load — including after a failure partway through. + dataset.finalize() + return root + + +def verify(repo_id: str, root: Path) -> None: + """Load the dataset back through LeRobot and report what it holds. + + Writing through LeRobot's API is not by itself proof the result loads: the + footers are written at ``finalize`` and the video shards are encoded + afterwards. Reading one sample back is the check that costs a second and + catches that, so "valid dataset" is something the tool demonstrates rather + than asserts. + """ + from lerobot.datasets.lerobot_dataset import LeRobotDataset + + dataset = LeRobotDataset(repo_id, root=root) + meta = dataset.meta + print( + f"\nverified: {meta.total_episodes} episode(s), {meta.total_frames} frames " + f"at {meta.fps} fps, robot {meta.robot_type}" + ) + sample = dataset[0] + for key in sorted(meta.features): + value = sample.get(key) + shape = tuple(value.shape) if hasattr(value, "shape") else type(value).__name__ + print(f" {key:<32} {shape}") + print(f" {'task':<32} {sample.get('task')!r}") + + +def main() -> None: + parser = argparse.ArgumentParser( + description="Export arm captures to a LeRobot dataset" + ) + parser.add_argument( + "--capture", required=True, help="$MOTE_HOME/episodes/" + ) + parser.add_argument( + "--repo-id", default=None, help="LeRobot repo id (default mote/)" + ) + parser.add_argument( + "--output", default=None, help="dataset root (default /lerobot)" + ) + parser.add_argument( + "--fps", type=int, default=None, help="override the capture's fps" + ) + parser.add_argument( + "--task", default=None, help="task string for episodes recorded without one" + ) + parser.add_argument( + "--images", + action="store_true", + help="store frames as images instead of encoding video (no ffmpeg needed)", + ) + parser.add_argument( + "--dry-run", action="store_true", help="report the plan, import nothing" + ) + parser.add_argument( + "--no-verify", + dest="verify", + action="store_false", + help="skip loading the dataset back after writing it", + ) + args = parser.parse_args() + + capture = Path(args.capture) + spec, planned = plan(capture, args.fps) + if args.repo_id is None: + args.repo_id = f"mote/{spec.name}" + + print(f"capture: {capture}") + print(f"repo id: {args.repo_id}") + print(f"fps: {spec.fps} robot: {spec.robot_type}") + print(f"joints: {', '.join(spec.joints)}") + print(f"camera: {spec.camera.key if spec.camera else 'none'}") + for path, raw, gridded in planned: + note = "" if raw == gridded else f" (resampled from {raw})" + print(f" {path.name}: {gridded} frames{note}") + + if args.dry_run: + print("\ndry run — nothing written") + return + + root = export(args) + print(f"\nwrote {root}") + if args.verify: + verify(args.repo_id, root) + print( + f"inspect it: pixi run -e lerobot -- lerobot-dataset-viz " + f"--repo-id {args.repo_id} --root {root} --episode-index 0" + ) + + +if __name__ == "__main__": + main() diff --git a/mote_bringup/launch/arm_launch.py b/mote_bringup/launch/arm_launch.py index f1ebd37..920a1a6 100644 --- a/mote_bringup/launch/arm_launch.py +++ b/mote_bringup/launch/arm_launch.py @@ -13,6 +13,10 @@ wheels, and the arm controller is loaded *inactive* — the arm is limp until `pixi run arm-jog` (or `switch_controllers --activate arm_controller`) asks it to hold. + +`mirror:=true` additionally runs `arm_mirror`, so a virtual-leader teleop +session is two terminals (this one and `pixi run arm-teleop`) rather than +three. See `mote_arm/TELEOP.md`. """ import os @@ -20,7 +24,9 @@ import yaml from ament_index_python.packages import get_package_share_directory from launch import LaunchDescription -from launch.substitutions import Command +from launch.actions import DeclareLaunchArgument +from launch.conditions import IfCondition +from launch.substitutions import Command, LaunchConfiguration from launch_ros.actions import Node, SetParameter from launch_ros.parameter_descriptions import ParameterValue @@ -78,6 +84,12 @@ def generate_launch_description(): return LaunchDescription( [ + DeclareLaunchArgument( + "mirror", + default_value="false", + description="also run arm_mirror, for virtual-leader teleop " + "(see mote_arm/TELEOP.md)", + ), SetParameter(name="use_sim_time", value=False), robot_state_publisher, controller_manager, @@ -86,5 +98,15 @@ def generate_launch_description(): active=("joint_state_broadcaster",), inactive=INACTIVE_CONTROLLERS, ), + # Off by default: `arm-jog`, `arm-pose` and episode replay all + # command arm_controller too, and none of them wants a second + # thing driving the arm in the same graph. + Node( + package="mote_arm", + executable="arm_mirror", + name="arm_mirror", + output="screen", + condition=IfCondition(LaunchConfiguration("mirror")), + ), ] ) diff --git a/pixi.lock b/pixi.lock index ec0f6c0..0012fe1 100644 --- a/pixi.lock +++ b/pixi.lock @@ -1,7 +1,17 @@ version: 7 platforms: - name: linux-64 + virtual-packages: + - __unix=0=0 + - __linux=4.18 + - __glibc=2.28 + - __archspec=0=x86_64 - name: linux-aarch64 + virtual-packages: + - __unix=0=0 + - __linux=4.18 + - __glibc=2.28 + - __archspec=0=aarch64 environments: default: channels: @@ -4299,6 +4309,282 @@ environments: - pypi: https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/e5/22/4222d7ddf3da30f363edaa98e329c2bce6c65497c9cb2810931c8b2c0fbc/fsspec-2026.6.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/fe/21/99a0cdaf54eb35e77623c41b5a2c9472ee4404bba687052791fe2aba6773/tqdm-4.69.0-py3-none-any.whl + lerobot: + channels: + - url: https://prefix.dev/mote/ + - url: https://conda.anaconda.org/robostack-jazzy/ + - url: https://conda.anaconda.org/conda-forge/ + indexes: + - https://pypi.org/simple + packages: + linux-64: + - conda: https://conda.anaconda.org/conda-forge/linux-64/_openmp_mutex-4.5-20_gnu.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/alsa-lib-1.2.16.1-hb03c661_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/aom-3.14.1-pl5321h039972f_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/bzip2-1.0.8-hda65f42_10.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/cairo-1.18.4-he90730b_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/dav1d-1.2.1-hd590300_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/dbus-1.16.2-h24cb091_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/ffmpeg-8.1.2-gpl_h54862ce_904.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/fontconfig-2.18.2-h27c8c51_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/freetype-2.14.3-ha770c72_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/fribidi-1.0.16-hb03c661_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/gdk-pixbuf-2.44.7-h2b0a6b4_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/glslang-16.5.0-h718be3e_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/gmp-6.3.0-hac33072_2.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/graphite2-1.3.15-hecca717_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/harfbuzz-14.3.0-ha770c72_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/icu-78.3-h54a6638_2.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/intel-gmmlib-22.10.0-hb700be7_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/intel-media-driver-26.1.6-hecca717_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/lame-3.100-h166bdaf_1003.tar.bz2 + - conda: https://conda.anaconda.org/conda-forge/linux-64/lcms2-2.19.1-h0c24ade_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/ld_impl_linux-64-2.46.1-default_hbd61a6d_102.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/lerc-4.2.0-hdb68285_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/level-zero-1.29.0-hb700be7_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libabseil-20260526.0-cxx17_h7b12aa8_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libass-0.17.5-h434b012_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libblas-3.11.0-8_h4a7cf45_openblas.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libbrotlicommon-1.2.0-hb03c661_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libbrotlidec-1.2.0-hb03c661_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libbrotlienc-1.2.0-hb03c661_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libcap-2.78-hd0affe5_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libcblas-3.11.0-8_h0358290_openblas.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libdeflate-1.25-hd45a770_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libdovi-3.4.0-ha23c83e_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libdrm-2.4.127-hb03c661_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libegl-1.7.0-ha4b6fd6_3.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libexpat-2.8.1-hecca717_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libffi-3.5.2-h3435931_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libflac-1.5.0-he200343_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libfreetype-2.14.3-ha770c72_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libfreetype6-2.14.3-h73754d4_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libgcc-16.1.0-ha9f2e26_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libgcc-ng-16.1.0-h69a702a_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libgfortran-16.1.0-h69a702a_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libgfortran5-16.1.0-h79bb938_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libgl-1.7.0-ha4b6fd6_3.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libglib-2.88.3-h0d30a3d_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libglvnd-1.7.0-ha4b6fd6_3.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libglx-1.7.0-ha4b6fd6_3.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libgomp-16.1.0-he0feb66_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libharfbuzz-14.3.0-h17a8019_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libharfbuzz-devel-14.3.0-h17a8019_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libhwloc-2.13.0-default_he001693_1000.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libhwy-1.4.0-h3a9caae_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libiconv-1.18-h3b78370_2.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libjpeg-turbo-3.2.0-hb03c661_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libjxl-0.12.0-h174a0a3_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/liblapack-3.11.0-8_h47877c9_openblas.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/liblzma-5.8.3-hb03c661_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libnsl-2.0.1-hb9d3cd8_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libogg-1.3.5-hd0c01bc_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libopenblas-0.3.33-pthreads_h94d23a6_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libopenvino-2026.2.1-h1f0fae8_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libopenvino-auto-batch-plugin-2026.2.1-h7e124b3_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libopenvino-auto-plugin-2026.2.1-h7e124b3_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libopenvino-hetero-plugin-2026.2.1-hd41364c_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libopenvino-intel-cpu-plugin-2026.2.1-h1f0fae8_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libopenvino-intel-gpu-plugin-2026.2.1-h1f0fae8_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libopenvino-intel-npu-plugin-2026.2.1-h1f0fae8_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libopenvino-ir-frontend-2026.2.1-hd41364c_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libopenvino-onnx-frontend-2026.2.1-h607c73d_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libopenvino-paddle-frontend-2026.2.1-h607c73d_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libopenvino-pytorch-frontend-2026.2.1-hecca717_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libopenvino-tensorflow-frontend-2026.2.1-h21c0c73_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libopenvino-tensorflow-lite-frontend-2026.2.1-hecca717_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libopus-1.6.1-h280c20c_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libpciaccess-0.19-hb03c661_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libplacebo-7.360.1-hc50b9dd_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libpng-1.6.58-h421ea60_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libprotobuf-7.35.1-h2840a7c_2.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/librsvg-2.62.3-h4c96295_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libsndfile-1.2.2-hc7d488a_2.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libsqlite-3.53.4-hf4e2dac_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libstdcxx-16.1.0-h934c35e_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libstdcxx-ng-16.1.0-hdf11a46_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libsystemd0-257.13-h084b8d7_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libtiff-4.7.2-h9d88235_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libudev1-257.13-h084b8d7_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libunwind-1.8.3-h65a8314_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/liburing-2.14-hb700be7_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libusb-1.0.29-h73b1eb8_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libuuid-2.42.2-h5347b49_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libva-2.24.1-he1eb515_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libvorbis-1.3.7-h54a6638_2.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libvpl-2.16.0-h54a6638_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libvpx-1.15.2-hecca717_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libvulkan-loader-1.4.357.0-h5279c79_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libwebp-base-1.6.0-hd42ef1d_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libxcb-1.17.0-h8a09558_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libxcrypt-4.4.38-h280c20c_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libxkbcommon-1.13.2-hca5e8e5_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libxml2-16-2.15.3-hca6bf5a_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libxml2-2.15.3-h49c6c72_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libzlib-1.3.2-h25fd6f3_3.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/mpg123-1.32.9-hc50e24c_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/ncurses-6.6-hdb14827_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/numpy-2.5.1-py312h33ff503_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/ocl-icd-2.3.4-hb03c661_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/opencl-headers-2025.06.13-hecca717_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/openh264-2.6.0-h65dd3cf_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/openjpeg-2.5.4-h55fea9a_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/openssl-3.6.3-h35e630c_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/pango-1.58.0-hda50119_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/pcre2-10.47-haa7fec5_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/pillow-12.3.0-py312h50c33e8_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/pixman-0.46.4-h54a6638_2.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/pthread-stubs-0.4-hb9d3cd8_1002.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/pugixml-1.15-h3f63f65_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/pulseaudio-client-17.0-h9a6aba3_3.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/python-3.12.13-hd63d673_0_cpython.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/readline-8.3-h853b02a_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/sdl2-2.32.56-h54a6638_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/sdl3-3.4.14-hdeec2a5_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/shaderc-2026.3-h1b60276_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/snappy-1.2.2-h03e3b7b_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/spirv-tools-2026.2-hb700be7_3.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/svt-av1-4.2.0-hecca717_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/tbb-2023.0.0-hab88423_2.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/tk-8.6.13-noxft_hd70dff1_3.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/wayland-1.26.0-hd6090a7_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/x264-1!164.3095-h166bdaf_2.tar.bz2 + - conda: https://conda.anaconda.org/conda-forge/linux-64/x265-3.5-h924138e_3.tar.bz2 + - conda: https://conda.anaconda.org/conda-forge/linux-64/xkeyboard-config-2.48-h280c20c_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libice-1.1.2-hb9d3cd8_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libsm-1.2.6-he73a12e_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libx11-1.8.13-he1eb515_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libxau-1.0.12-hb03c661_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libxcursor-1.2.3-hb9d3cd8_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libxdmcp-1.1.5-hb03c661_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libxext-1.3.7-hb03c661_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libxfixes-6.0.2-hb03c661_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libxi-1.8.3-hb03c661_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libxrandr-1.5.5-hb03c661_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libxrender-0.9.12-hb9d3cd8_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libxscrnsaver-1.2.4-hb9d3cd8_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libxtst-1.2.5-hb9d3cd8_3.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/zlib-ng-2.3.3-hceb46e0_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/zstd-1.5.7-hb78ec9c_6.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.7.22-hbd8a1cb_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/font-ttf-dejavu-sans-mono-2.37-hab24e00_0.tar.bz2 + - conda: https://conda.anaconda.org/conda-forge/noarch/font-ttf-inconsolata-3.000-h77eed37_0.tar.bz2 + - conda: https://conda.anaconda.org/conda-forge/noarch/font-ttf-source-code-pro-2.038-h77eed37_0.tar.bz2 + - conda: https://conda.anaconda.org/conda-forge/noarch/font-ttf-ubuntu-0.83-h77eed37_3.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/fonts-conda-ecosystem-1-0.tar.bz2 + - conda: https://conda.anaconda.org/conda-forge/noarch/fonts-conda-forge-1-hc364b38_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/python_abi-3.12-8_cp312.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2026c-h151e31d_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/wayland-protocols-1.49-hd8ed1ab_0.conda + - pypi: https://files.pythonhosted.org/packages/01/c4/4fa4c8b3097a11f3c5f09a35b72ed6855fb1d332469504962ab7bafcc702/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/05/6b/32f747947df2da6994e999492ab306a903659555dddc0fbdeb9d71f75e52/nvidia_cuda_nvrtc_cu12-12.8.93-py3-none-manylinux2010_x86_64.manylinux_2_12_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/07/bc/587a445451b253b285629263eb51c2d8e9bcea4fc97826266d186f96f558/pyserial-3.5-py2.py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/0d/9b/a997b638fcd068ad6e4d53b8551a7d30fe8b404d6f1804abf1df69838932/nvidia_cuda_runtime_cu12-12.8.90-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/12/27/fb8d7338b4d551900fa3e580acbe7a0cf655d940e164cb5c00ec31961094/orderly_set-5.5.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/12/98/e976540c1addf70442be7842a18cf70884a964abbf69442504f4d2939989/pandas-3.0.5-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/14/15/5574111ae50dd6e879456888c0eadd4c5a869959775854e18e18a6b345f3/propcache-0.5.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/16/92/d1e32e3e0d894fe00b15ce28ad4944ab692713f2e7f0a99787405e43533a/protobuf-6.33.6-cp39-abi3-manylinux2014_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/18/96/34db781c8f0cf99c544ca1f2bc2e5bf55426e1eb4ca6de8ea5da56a9f352/xxhash-3.8.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/1e/77/dc8c558f7593132cf8fefec57c4f60c83b16941c574ac5f619abb3ae7933/dill-0.4.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/1f/13/ee4e00f30e676b66ae65b4f08cb5bcbb8392c03f54f2d5413ea99a5d1c80/nvidia_cufft_cu12-11.3.3.83-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/28/50/f203ff3a3ddfe19308efc83c5a3a29ed02bf786732ec35e68bf9162f3365/safetensors-0.8.0-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/29/34/ccc711b6dc581e43b8d8d227e4173a8826994ee7b68d6b3d82291f307325/torchcodec-0.10.0-cp312-cp312-manylinux_2_28_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/2a/09/f8d8f8f31e4483c10a906437b4ce31bdf3d6d417b73fe33f1a8b59e34228/einops-0.8.2-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/2a/2e/38d9824f8c6bb048c5ba21c6d4da54c29c162a46b58b3ef907a360a76d3e/diffusers-0.35.2-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/2a/8e/096d00c7c480ef2ff4265349b14e2261d4ab787ba1f74e2e80d1c58079c3/regex-2026.7.19-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/2b/5f/c52bd1255db763d0cdcb7084d2e90c42119cb229302c56bdf1d0aa78abd2/deepdiff-8.6.2-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/2c/19/04f9b178c2d8a15b076c8b5140708fa6ffc5601fb6f1e975537072df5b2a/mergedeep-1.3.4-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/31/a0/651f93d154cb72323358bf2bbae3e642bdb5d2f1bfc874d096f7cb159fa0/huggingface_hub-0.35.3-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/33/d1/8bb87d21e9aeb323cc03034f5eaf2c8f69841e40e4853c2627edf8111ed3/termcolor-3.3.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/38/3d/2d244233ac4f76e38533cfcb2991c9eb4c7bf688ae0a036d30725b8faafe/importlib_metadata-9.0.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/3a/13/547360d81e6d88d58492968ffda9f9542854f11310ee556fef14260cc886/zipp-4.1.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/3e/2d/ca050652104bab2cf55e569db2a178b1b61cb041fef28307f2db383f6d9f/imageio-2.37.4-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/43/e3/7d92a15f894aa0c9c4b49b8ee9ac9850d6e63b03c9c32c0367a13ae62209/mpmath-1.3.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/44/6f/7120676b6d73228c96e17f1f794d8ab046fc910d781c8d151120c3f1569e/toml-0.10.2-py2.py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/52/b7/7cd31f29d6055bd711ae6e669367fba6f5ae9de463910a793e30556a8db7/aiohttp-3.14.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/54/6f/84908cad2d6aa5144abcf7b42709fe4fdb459bc640ec7ac5786e7693dabc/prompt_toolkit-3.0.53-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/55/0e/cd705c042bc4fe7022478db577fcab4abdcfabb9bc37ab7a75556b3fcb2b/pyarrow-25.0.0-cp312-cp312-manylinux_2_28_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/56/79/12978b96bd44274fe38b5dde5cfb660b1d114f70a65ef962bcbbed99b549/nvidia_cusparselt_cu12-0.7.1-py3-none-manylinux2014_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/5f/97/2aab507d3d00ca626e8e57c1eac6a79e4e5fbcc63eb99733ff55d1717f65/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/65/99/00f3196036501b53032c4b1ab8337a0b978dee832ed276dae3815df4e8b5/datasets-4.8.5-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/65/f3/107a22063bf27bdccf2024833d3445f4eea42b2e598abfbd46f6a63b6cb0/typing_inspect-0.9.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/67/4e/a28359bf1c1ecf11eba22123168c138698f7cb576ac678f5a2e16cd5da08/hf_xet-1.6.0-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/68/2f/f24b039169db474e8688f649377de082a965fbf85daf4e46c44412f1d15a/torchvision-0.25.0-cp312-cp312-manylinux_2_28_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/6a/bd/d91c5e39f490a49df14320f4e8c80161cfcce09f1e2cde1edd16a551abb3/frozenlist-1.8.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/6d/98/bbeb760852adb27f166ce1617f0e51aabb15f21b1e60ea703f2aed3c78ac/pynput-1.8.2-py2.py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/6e/89/f7a07dc961b60645dbbf42e80f2bc85ade7feb9a491b11a1e973aa00071f/nvidia_nccl_cu12-2.27.5-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/70/c8/1b758bd903afee000f023cd03f335ff328a21b3914f9f9deda49b1e57723/wandb-0.24.2-py3-none-manylinux_2_28_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/71/43/1947f06babed6b3f1d7f38b0c767f52df66bfb2bc10b468c4a7de9eceff2/aiohappyeyeballs-2.7.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/71/70/38998b950a97ea279e6bd657575d22d1a2047256caf707d9a10fbce4f065/multiprocess-0.70.19-py312-none-any.whl + - pypi: https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/7c/f0/21f81892e4ed10f4ec3ef2e7cf8635fb76e7c0907c55d0da66be50094760/farama_notifications-0.0.6-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/7d/68/d8d58938dfb1370b266a1a729e6d77a985be23689a0496498ee17b2cbf90/platformdirs-4.11.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/85/48/9a13d2975803e8cf2777d5ed57b87a0b6ca2cc795f9a4f59796a910bfb80/nvidia_cusolver_cu12-11.7.3.90-py3-none-manylinux_2_27_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/88/39/799be3f2f0f38cc727ee3b4f1445fe6d5e4133064ec2e4115069418a5bb6/cloudpickle-3.1.2-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/89/d3/726bd88f0eece09ddf431bea4c9191c18e7a8d070b854eb0014d447712ee/sentry_sdk-2.66.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/8c/d7/8ff98376b1acc4503253b685ea09981697385ce344d4e3935c2af49e044d/pfzy-0.3.4-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/94/b8/f1f62a5e3c0ad2ff1d189590bfa4c46b4f3b6e49cef6f26c6ee4e575394d/setuptools-80.10.2-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/96/42/3e5985a0a7e57de470b320c6d6a1a67c844f6737a587f3d44dd13d1819e7/wcwidth-0.8.2-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/9e/c9/b2622292ea83fbb4ec318f5b9ab867d0a28ab43c5717bb85b0a5f6b3b0a4/networkx-3.6.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/a0/2d/43c8522a2038e9d0e7dbdf3a61195ecc31ca576fb1527a528c877e87d973/imageio_ffmpeg-0.6.0-py3-none-manylinux2014_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/a0/61/5c78b91c3143ed5c14207f463aecfc8f9dbb5092fb2869baf37c273b2705/gitdb-4.0.12-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/a2/09/77d55d46fd61b4a135c444fc97158ef34a095e5681d0a6c10b75bf356191/sympy-1.14.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/a2/eb/86626c1bbc2edb86323022371c39aa48df6fd8b0a1647bc274577f72e90b/nvidia_nvtx_cu12-12.8.90-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/a5/f5/397b61091120a9ca5001041dd7bf76c385b3bfd67a0e5bcb74b852bd22a4/evdev-1.9.3.tar.gz + - pypi: https://files.pythonhosted.org/packages/a8/db/253133d7e7cb40d3af384bb2f5c0b4a2b7fdcffbc95c688cc67a20a3c103/accelerate-1.14.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/a9/c1/dabe88f52c3e3760d861401bb994df08f672ec893b8f7592dc91626adcf3/cuda_bindings-12.9.4-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/ab/a8/cdf8b3e4c98132f965f88c2313a4b493266832ad47fb52f23d14d4f86bb5/triton-3.6.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/ac/13/37737ef2193e83862ccacff23580c39de251da456a1bf0459e762cca273c/av-15.1.0-cp312-cp312-manylinux_2_28_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/b3/7a/abada41517ce0011775f0f4eacc79659bc9bc6c361e6bfe6f7052a6b9363/torch-2.10.0-3-cp312-cp312-manylinux_2_28_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/b5/09/6ea3ea725f82e1e76684f0708bbedd871fc96da89945adeba65c3835a64c/nvidia_nvshmem_cu12-3.4.5-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/b5/70/5d8df3b09e25bce090399cf48e452d25c935ab72dad19406c77f4e828045/psutil-7.2.2-cp36-abi3-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/ba/51/e123d997aa098c61d029f76663dedbfb9bc8dcf8c60cbd6adbe42f76d049/nvidia_cudnn_cu12-9.10.2.21-py3-none-manylinux_2_27_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/bb/fe/1bcba1dfbfb8d01be8d93f07bfc502c93fa23afa6fd5ab3fc7c1df71038a/nvidia_cufile_cu12-1.13.1.3-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/c1/d4/59e74daffcb57a07668852eeeb6035af9f32cbfd7a1d2511f17d2fe6a738/smmap-5.0.3-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/c1/e8/72f8cef9fdfeffe06213fe8508039396ee48daa0e3259457ed766173bfd6/filelock-3.32.2-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/c2/f5/e1854cb2f2bcd4280c44736c93550cc300ff4b8c95ebe370d0aa7d2b473d/nvidia_cusparse_cu12-12.5.8.93-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/c4/9a/a83083b230d352ee5d205757b74006dbe084448ca45e3bc5ca99215b1e55/draccus-0.10.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/cb/2d/2c43b7d99346b04925313f485b8f99596aeb8094f556d4312da9f2e1ca60/lerobot-0.4.4-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/ce/ff/3b59672c47c6284e8005b42e84ceba13864aa0f39f067c973d1af02f5d91/InquirerPy-0.3.4-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/d5/ca/6a2cc3a73170d10b5af1f1613baa2ed1f8f46f62dd0bfab2bffd2c2fe260/pyyaml_include-1.4.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/d6/d0/d56c859b8222116f5d68459199f48359e0bf121b6f65a69bf329b3602ba0/yarl-1.24.5-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/d6/d8/f87ea6f42456254b48915970ed98e993110521e9263472840174d32c880d/hf_transfer-0.1.9-cp38-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/da/35/f2287558c17e29fafc8ef3daf819bb9834061cfa43bff8014f7df7f63bdc/anyio-4.14.2-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/dc/61/e24b560ab2e2eaeb3c839129175fb330dfcfc29e5203196e5541a4c44682/nvidia_cublas_cu12-12.8.4.1-py3-none-manylinux_2_27_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/dd/5c/c139a7876099916879609372bfa513b7f1257f7f1a908b0bdc1c2328241b/opencv_python_headless-4.11.0.86-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/e0/6c/323c40671c6f1b3e02bb4a7404fbe2bf653190a56e63cf4b6a4f06e876bc/cmake-4.1.3-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/e6/ab/fb21f4c939bb440104cc2b396d3be1d9b7a9fd3c6c2a53d98c45b3d7c954/fsspec-2026.2.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/e9/73/fda6a25f3beeb5e49d74330b44092b9e5a547395ccd478d1103ddcbff1fc/gymnasium-1.3.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/ec/0c/9d8752098bc442f0726e64aa6135940b3a96809915d1aa4206c1bb97881d/gitpython-3.1.58-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/f3/8d/5e5be3ced1d12966fefb5c4ea3b2a5b480afcea36406559442c6e31d4a48/multidict-6.7.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/f5/86/0b9c8f56398b4fc85f8e99279907c258413a297e5603f8f2537fe5806e51/rerun_sdk-0.26.2-cp39-abi3-manylinux_2_28_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/f6/74/86a07f1d0f42998ca31312f998bd3b9a7eff7f52378f4f270c8679c77fb9/nvidia_nvjitlink_cu12-12.8.93-py3-none-manylinux2010_x86_64.manylinux_2_12_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/f8/02/2adcaa145158bf1a8295d83591d22e4103dbfd821bcaf6f3f53151ca4ffa/nvidia_cuda_cupti_cu12-12.8.90-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/f8/62/d9ba6323b9202dd2fe166beab8a86d29465c41a0288cbe229fac60c1ab8d/jsonlines-4.0.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/f9/1c/01bfd571a64e7f270e6bab5e33777debe0edc56759233ce84f27dec92d14/tqdm-4.70.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/fb/aa/6584b56dc84ebe9cf93226a5cde4d99080c8e90ab40f0c27bda7a0f29aa1/nvidia_curand_cu12-10.3.9.90-py3-none-manylinux_2_27_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/fb/e2/79c688af8b210d232694e31e59da9f6ec747bae31c3f5946e4e9b98860d5/click-8.4.2-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/fc/b4/d088047afe39827556df21118cac9ffd20cc3f968c99a7681494d1eb333c/cuda_pathfinder-1.6.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/fc/b8/ff33610932e0ee81ae7f1269c890f697d56ff74b9f5b2ee5d9b7fa2c5355/python_xlib-0.33-py2.py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl lint: channels: - url: https://prefix.dev/mote/ @@ -6950,6 +7236,20 @@ packages: purls: [] size: 42581149 timestamp: 1761041037901 +- conda: https://conda.anaconda.org/conda-forge/linux-64/bzip2-1.0.8-hda65f42_10.conda + sha256: 1a0d382c515ebf55f8ee1f38c8b81bc95af5c2acc42ad53b66bc5df932032f96 + md5: e675fabcf81499adc7edf58124fb1e01 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + license: bzip2-1.0.6 + license_family: BSD + purls: [] + run_exports: + weak: + - bzip2 >=1.0.8,<2.0a0 + size: 257808 + timestamp: 1785906269155 - conda: https://conda.anaconda.org/conda-forge/linux-64/bzip2-1.0.8-hda65f42_9.conda sha256: 0b75d45f0bba3e95dc693336fa51f40ea28c980131fec438afb7ce6118ed05f6 md5: d2ffd7602c02f2b316fd921d39876885 @@ -7030,6 +7330,9 @@ packages: - xorg-libxrender >=0.9.12,<0.10.0a0 license: LGPL-2.1-only or MPL-1.1 purls: [] + run_exports: + weak: + - cairo >=1.18.4,<2.0a0 size: 989514 timestamp: 1766415934926 - conda: https://conda.anaconda.org/conda-forge/linux-64/capnproto-1.4.0-h791c776_0.conda @@ -7450,6 +7753,9 @@ packages: license: BSD-2-Clause license_family: BSD purls: [] + run_exports: + weak: + - dav1d >=1.2.1,<1.2.2.0a0 size: 760229 timestamp: 1685695754230 - conda: https://conda.anaconda.org/conda-forge/linux-64/dbus-1.16.2-h24cb091_1.conda @@ -7464,6 +7770,9 @@ packages: - libexpat >=2.7.3,<3.0a0 license: AFL-2.1 OR GPL-2.0-or-later purls: [] + run_exports: + weak: + - dbus >=1.16.2,<2.0a0 size: 447649 timestamp: 1764536047944 - conda: https://conda.anaconda.org/conda-forge/linux-64/double-conversion-3.4.0-hecca717_0.conda @@ -7599,6 +7908,74 @@ packages: purls: [] size: 1590323 timestamp: 1736132840079 +- conda: https://conda.anaconda.org/conda-forge/linux-64/ffmpeg-8.1.2-gpl_h54862ce_904.conda + sha256: 35accb6f0fe430d89ca22e240ea3c7d964c23acfba637d40807dbf9207c96fbd + md5: 5cfeaeb36151f58c6166eb30eaa7d06a + depends: + - __glibc >=2.17,<3.0.a0 + - alsa-lib >=1.2.16.1,<1.3.0a0 + - aom >=3.14.1,<3.15.0a0 + - bzip2 >=1.0.8,<2.0a0 + - dav1d >=1.2.1,<1.2.2.0a0 + - fontconfig >=2.18.1,<3.0a0 + - fonts-conda-ecosystem + - gmp >=6.3.0,<7.0a0 + - lame >=3.100,<3.101.0a0 + - libass >=0.17.5,<0.17.6.0a0 + - libexpat >=2.8.1,<3.0a0 + - libfreetype >=2.14.3 + - libfreetype6 >=2.14.3 + - libgcc >=14 + - libharfbuzz >=14.2.1 + - libiconv >=1.18,<2.0a0 + - libjxl >=0.12.0,<0.13.0a0 + - liblzma >=5.8.3,<6.0a0 + - libopenvino >=2026.2.1,<2026.2.2.0a0 + - libopenvino-auto-batch-plugin >=2026.2.1,<2026.2.2.0a0 + - libopenvino-auto-plugin >=2026.2.1,<2026.2.2.0a0 + - libopenvino-hetero-plugin >=2026.2.1,<2026.2.2.0a0 + - libopenvino-intel-cpu-plugin >=2026.2.1,<2026.2.2.0a0 + - libopenvino-intel-gpu-plugin >=2026.2.1,<2026.2.2.0a0 + - libopenvino-intel-npu-plugin >=2026.2.1,<2026.2.2.0a0 + - libopenvino-ir-frontend >=2026.2.1,<2026.2.2.0a0 + - libopenvino-onnx-frontend >=2026.2.1,<2026.2.2.0a0 + - libopenvino-paddle-frontend >=2026.2.1,<2026.2.2.0a0 + - libopenvino-pytorch-frontend >=2026.2.1,<2026.2.2.0a0 + - libopenvino-tensorflow-frontend >=2026.2.1,<2026.2.2.0a0 + - libopenvino-tensorflow-lite-frontend >=2026.2.1,<2026.2.2.0a0 + - libopus >=1.6.1,<2.0a0 + - libplacebo >=7.360.1,<7.361.0a0 + - librsvg >=2.62.3,<3.0a0 + - libstdcxx >=14 + - libva >=2.24.1,<3.0a0 + - libvorbis >=1.3.7,<1.4.0a0 + - libvpl >=2.16.0,<2.17.0a0 + - libvpx >=1.15.2,<1.16.0a0 + - libvulkan-loader >=1.4.341.0,<2.0a0 + - libwebp-base >=1.6.0,<2.0a0 + - libxcb >=1.17.0,<2.0a0 + - libxml2 + - libxml2-16 >=2.14.6 + - libzlib >=1.3.2,<2.0a0 + - openh264 >=2.6.0,<2.6.1.0a0 + - openssl >=3.5.7,<4.0a0 + - pulseaudio-client >=17.0,<17.1.0a0 + - sdl2 >=2.32.56,<3.0a0 + - shaderc >=2026.3,<2026.4.0a0 + - svt-av1 >=4.2.0,<4.2.1.0a0 + - x264 >=1!164.3095,<1!165 + - x265 >=3.5,<3.6.0a0 + - xorg-libx11 >=1.8.13,<2.0a0 + constrains: + - __cuda >=12.8 + license: GPL-2.0-or-later + license_family: GPL + purls: [] + run_exports: + weak: + - ffmpeg >=8.1.2,<9.0a0 + size: 13046477 + timestamp: 1784313887787 - conda: https://conda.anaconda.org/conda-forge/linux-64/ffmpeg-8.1.2-gpl_h6d6c1bd_900.conda sha256: 733a4d28449d2011924c60038205d1cf8fbb6ab17bbc14d0292ff3f1ad5e2000 md5: f67dd5264815883b92396d0dddbfce78 @@ -7851,6 +8228,20 @@ packages: purls: [] size: 173839 timestamp: 1774298173462 +- conda: https://conda.anaconda.org/conda-forge/linux-64/freetype-2.14.3-ha770c72_1.conda + sha256: d109354d4584aa1ef6e915a8f02cbe13a9708f384aa167c075953b6f8196111c + md5: 8dd74ae1edf2b6490af0e960be773431 + depends: + - libfreetype 2.14.3 ha770c72_1 + - libfreetype6 2.14.3 h73754d4_1 + license: GPL-2.0-only OR FTL + purls: [] + run_exports: + weak: + - libfreetype >=2.14.3 + - libfreetype6 >=2.14.3 + size: 174748 + timestamp: 1785641176027 - conda: https://conda.anaconda.org/conda-forge/linux-64/freexl-2.0.0-h9dce30a_2.conda sha256: c8960e00a6db69b85c16c693ce05484facf20f1a80430552145f652a880e0d2a md5: ecb5d11305b8ba1801543002e69d2f2f @@ -7875,6 +8266,19 @@ packages: purls: [] size: 61244 timestamp: 1757438574066 +- conda: https://conda.anaconda.org/conda-forge/linux-64/fribidi-1.0.16-hb03c661_1.conda + sha256: 4846a3ca0402f3fe33ad84ed50ab213c6aafde4a0faef3c5002f6bf753e21671 + md5: 1cd10eda5692519d01bb20e086e214c9 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + license: LGPL-2.1-or-later + purls: [] + run_exports: + weak: + - fribidi >=1.0.16,<2.0a0 + size: 61782 + timestamp: 1785912528684 - conda: https://conda.anaconda.org/conda-forge/linux-64/frozenlist-1.7.0-py312h447239a_0.conda sha256: f4e0e6cd241bc24afb2d6d08e5d2ba170fad2475e522bdf297b7271bba268be6 md5: 63e20cf7b7460019b423fc06abb96c60 @@ -7973,6 +8377,25 @@ packages: purls: [] size: 577414 timestamp: 1774985848058 +- conda: https://conda.anaconda.org/conda-forge/linux-64/gdk-pixbuf-2.44.7-h2b0a6b4_0.conda + sha256: 1c22e37f9d7e06e9e0582ee5a55c2ddd19ea75f71f44eb13b56f504ef5c37aa5 + md5: 5d355db3e937086e22cf4cb5fe19787c + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - libglib >=2.88.2,<3.0a0 + - libjpeg-turbo >=3.1.4.1,<4.0a0 + - liblzma >=5.8.3,<6.0a0 + - libpng >=1.6.58,<1.7.0a0 + - libtiff >=4.7.1,<4.8.0a0 + license: LGPL-2.1-or-later + license_family: LGPL + purls: [] + run_exports: + weak: + - gdk-pixbuf >=2.44.7,<3.0a0 + size: 581631 + timestamp: 1782591374199 - conda: https://conda.anaconda.org/conda-forge/linux-64/geographiclib-cpp-2.7-hb700be7_0.conda sha256: 8d05d60178581a7a5f6cd3266cd5172f9cf18c89c8751ca9c76704f7b1b45916 md5: 4c9ffd9d8888d6e17ea4f2ebfb02ec73 @@ -8237,6 +8660,22 @@ packages: purls: [] size: 1366082 timestamp: 1777747028121 +- conda: https://conda.anaconda.org/conda-forge/linux-64/glslang-16.5.0-h718be3e_1.conda + sha256: b213106181aa7bb52e202ddaef411f106a2e9d641f1ee618fd7ce9e30b265f9b + md5: 3e8c7b2e4ddda1d61b8b3afa01be7d97 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - libstdcxx >=14 + - spirv-tools >=2026,<2027.0a0 + license: BSD-3-Clause + license_family: BSD + purls: [] + run_exports: + weak: + - glslang >=16,<17.0a0 + size: 1395808 + timestamp: 1785879900020 - conda: https://conda.anaconda.org/conda-forge/linux-64/gmock-1.17.0-ha770c72_1.conda sha256: 80ca13dc518962fcd86856586cb5fb612fe69914234eab322f9dee25f628090f md5: 33e7a8280999b958df24a95f0cb86b1a @@ -8814,6 +9253,19 @@ packages: - libharfbuzz >=14.2.1 size: 11039 timestamp: 1782800611635 +- conda: https://conda.anaconda.org/conda-forge/linux-64/harfbuzz-14.3.0-ha770c72_0.conda + sha256: 511813aaad42ed2494efc77452738fed7734985e069efc08c279a701b1708ba0 + md5: 7f42f814e1306f83e4cac267baa9acab + depends: + - libharfbuzz-devel 14.3.0 h17a8019_0 + license: MIT + license_family: MIT + purls: [] + run_exports: + weak: + - libharfbuzz >=14.3.0 + size: 11045 + timestamp: 1785770087614 - conda: https://conda.anaconda.org/conda-forge/linux-64/hdf4-4.2.15-h2a13503_7.conda sha256: 0d09b6dc1ce5c4005ae1c6a19dc10767932ef9a5e9c755cfdbb5189ac8fb0684 md5: bd77f8da987968ec3927990495dc22e4 @@ -8930,6 +9382,7 @@ packages: - __glibc >=2.17,<3.0.a0 license: MIT license_family: MIT + purls: [] run_exports: weak: - icu >=78.3,<79.0a0 @@ -8976,6 +9429,9 @@ packages: license: MIT license_family: MIT purls: [] + run_exports: + weak: + - intel-gmmlib >=22.10.0,<23.0a0 size: 1013714 timestamp: 1774422680665 - conda: https://conda.anaconda.org/conda-forge/linux-64/intel-media-driver-26.1.6-hecca717_0.conda @@ -9130,6 +9586,9 @@ packages: license: LGPL-2.0-only license_family: LGPL purls: [] + run_exports: + weak: + - lame >=3.100,<3.101.0a0 size: 508258 timestamp: 1664996250081 - conda: https://conda.anaconda.org/conda-forge/linux-64/lcms2-2.19.1-h0c24ade_0.conda @@ -9213,6 +9672,7 @@ packages: - libstdcxx >=14 license: Apache-2.0 license_family: Apache + purls: [] run_exports: weak: - lerc >=4.2.0,<5.0a0 @@ -9262,6 +9722,7 @@ packages: - libabseil-static =20260526.0=cxx17* license: Apache-2.0 license_family: Apache + purls: [] run_exports: weak: - libabseil >=20260526.0,<20260527.0a0 @@ -9380,6 +9841,27 @@ packages: purls: [] size: 152179 timestamp: 1749328931930 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libass-0.17.5-h434b012_0.conda + sha256: 24d4b59a0267e1c159c3af82df106b42faeefccceba3c489044c93abf113c503 + md5: c1cb4d6e8a6e3f724740dee5346fc8b4 + depends: + - libgcc >=14 + - __glibc >=2.17,<3.0.a0 + - libfreetype >=2.14.3 + - libfreetype6 >=2.14.3 + - libzlib >=1.3.2,<2.0a0 + - fribidi >=1.0.16,<2.0a0 + - fontconfig >=2.18.1,<3.0a0 + - fonts-conda-ecosystem + - libiconv >=1.18,<2.0a0 + - harfbuzz >=14.2.1 + license: ISC + purls: [] + run_exports: + weak: + - libass >=0.17.5,<0.17.6.0a0 + size: 154964 + timestamp: 1782298715788 - conda: https://conda.anaconda.org/conda-forge/linux-64/libattr-2.5.2-hb03c661_1.conda sha256: 0cef37eb013dc7091f17161c357afbdef9a9bc79ef6462508face6db3f37db77 md5: 7e7f0a692eb62b95d3010563e7f963b6 @@ -9581,6 +10063,9 @@ packages: license: MIT license_family: MIT purls: [] + run_exports: + weak: + - libbrotlicommon >=1.2.0,<1.3.0a0 size: 79965 timestamp: 1764017188531 - conda: https://conda.anaconda.org/conda-forge/linux-64/libbrotlidec-1.2.0-hb03c661_1.conda @@ -9593,6 +10078,9 @@ packages: license: MIT license_family: MIT purls: [] + run_exports: + weak: + - libbrotlidec >=1.2.0,<1.3.0a0 size: 34632 timestamp: 1764017199083 - conda: https://conda.anaconda.org/conda-forge/linux-64/libbrotlienc-1.2.0-hb03c661_1.conda @@ -9605,6 +10093,9 @@ packages: license: MIT license_family: MIT purls: [] + run_exports: + weak: + - libbrotlienc >=1.2.0,<1.3.0a0 size: 298378 timestamp: 1764017210931 - conda: https://conda.anaconda.org/conda-forge/linux-64/libbtf-2.3.2-hf02c80a_7100101.conda @@ -9641,6 +10132,20 @@ packages: purls: [] size: 124432 timestamp: 1774333989027 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libcap-2.78-hd0affe5_0.conda + sha256: cc8c9fc6ddf0fbd3d1275b558ae9abad6cda23bced268732e2da21a87bb358cd + md5: f9f17eab7f3df1c6fd4b1a548a2f683a + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + license: BSD-3-Clause + license_family: BSD + purls: [] + run_exports: + weak: + - libcap >=2.78,<2.79.0a0 + size: 124335 + timestamp: 1775488792584 - conda: https://conda.anaconda.org/conda-forge/linux-64/libcblas-3.11.0-7_h0358290_openblas.conda build_number: 7 sha256: 956ae0bb1ec8b0c3663d75b151aceb0521b54e513bf97f621a035f9c87037970 @@ -9892,6 +10397,19 @@ packages: - libdeflate >=1.25,<1.26.0a0 size: 73490 timestamp: 1761979956660 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libdeflate-1.25-hd45a770_1.conda + sha256: 82e134c8a08b1eed9a2ed8ab578b89aa1730dcde3dea8dd87645ed0637878e54 + md5: 40f9b31aa9cf007789867df0decd0492 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + license: MIT + purls: [] + run_exports: + weak: + - libdeflate >=1.25,<1.26.0a0 + size: 73710 + timestamp: 1785908694612 - conda: https://conda.anaconda.org/conda-forge/linux-64/libdovi-3.4.0-ha23c83e_0.conda sha256: cea351b57c30d70e288b53ea69a1dcf6b750992f5d7717a7fc364072fa1209e7 md5: 4377d220f09344452b227d699cacce4f @@ -9918,6 +10436,9 @@ packages: license: MIT license_family: MIT purls: [] + run_exports: + weak: + - libdrm >=2.4.127,<2.5.0a0 size: 311505 timestamp: 1778975798004 - conda: https://conda.anaconda.org/conda-forge/linux-64/libedit-3.1.20250104-pl5321h7949ede_0.conda @@ -9951,6 +10472,7 @@ packages: - libglvnd 1.7.0 ha4b6fd6_3 license: LicenseRef-libglvnd purls: [] + run_exports: {} size: 46500 timestamp: 1779728188901 - conda: https://conda.anaconda.org/conda-forge/linux-64/libegl-devel-1.7.0-ha4b6fd6_2.conda @@ -10051,6 +10573,9 @@ packages: license: BSD-3-Clause license_family: BSD purls: [] + run_exports: + weak: + - libflac >=1.5.0,<1.6.0a0 size: 424563 timestamp: 1764526740626 - conda: https://conda.anaconda.org/conda-forge/linux-64/libfreetype-2.14.3-ha770c72_0.conda @@ -10063,6 +10588,16 @@ packages: run_exports: {} size: 8049 timestamp: 1774298163029 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libfreetype-2.14.3-ha770c72_1.conda + sha256: 9f09d889d1021fa0d99968e5f209a7a7b316dee48c97ed0d79e996e1272a6280 + md5: 12a05d10f2e4eadfd7b8f754a17f5e1a + depends: + - libfreetype6 >=2.14.3 + license: GPL-2.0-only OR FTL + purls: [] + run_exports: {} + size: 8419 + timestamp: 1785641173212 - conda: https://conda.anaconda.org/conda-forge/linux-64/libfreetype6-2.14.3-h73754d4_0.conda sha256: 16f020f96da79db1863fcdd8f2b8f4f7d52f177dd4c58601e38e9182e91adf1d md5: fb16b4b69e3f1dcfe79d80db8fd0c55d @@ -10078,6 +10613,21 @@ packages: run_exports: {} size: 384575 timestamp: 1774298162622 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libfreetype6-2.14.3-h73754d4_1.conda + sha256: 162f1736f9ec7b19915658cbb25a685748932f697418ab85657332de5da5f496 + md5: e63acf9b3849fd9fc2dba64b69849716 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - libpng >=1.6.58,<1.7.0a0 + - libzlib >=1.3.2,<2.0a0 + constrains: + - freetype >=2.14.3 + license: GPL-2.0-only OR FTL + purls: [] + run_exports: {} + size: 386042 + timestamp: 1785641172605 - conda: https://conda.anaconda.org/conda-forge/linux-64/libgcc-15.2.0-he0feb66_19.conda sha256: 8e0a3b5e41272e5678499b5dfc4cddb673f9e935de01eb0767ce857001229f46 md5: 57736f29cc2b0ec0b6c2952d3f101b6a @@ -10117,6 +10667,8 @@ packages: - libgomp 16.1.0 he0feb66_1 - libgcc-ng ==16.1.0=*_1 license: GPL-3.0-only WITH GCC-exception-3.1 + license_family: GPL + purls: [] run_exports: {} size: 1057877 timestamp: 1785375436766 @@ -10133,6 +10685,19 @@ packages: - libgcc size: 27694 timestamp: 1778269016987 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libgcc-ng-16.1.0-h69a702a_1.conda + sha256: 225275c562337a1cd61705da0ee4235dde7bba7504de1c34b74c894adb2b0eee + md5: 7ed870c014a6f23c7dfafda53d2763a9 + depends: + - libgcc 16.1.0 ha9f2e26_1 + license: GPL-3.0-only WITH GCC-exception-3.1 + license_family: GPL + purls: [] + run_exports: + strong: + - libgcc + size: 28210 + timestamp: 1785375440733 - conda: https://conda.anaconda.org/conda-forge/linux-64/libgd-2.3.3-h5fbf134_12.conda sha256: 245be793e831170504f36213134f4c24eedaf39e634679809fd5391ad214480b md5: 88c1c66987cd52a712eea89c27104be6 @@ -10834,6 +11399,19 @@ packages: run_exports: {} size: 27655 timestamp: 1778269042954 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libgfortran-16.1.0-h69a702a_1.conda + sha256: 9e82d410a50bd4e5e47cbbb026454c3eb543954baca4db23929575b571bf56a3 + md5: 2fbed65cc90cf0724e1ec4de13696737 + depends: + - libgfortran5 16.1.0 h79bb938_1 + constrains: + - libgfortran-ng ==16.1.0=*_1 + license: GPL-3.0-only WITH GCC-exception-3.1 + license_family: GPL + purls: [] + run_exports: {} + size: 28134 + timestamp: 1785375470055 - conda: https://conda.anaconda.org/conda-forge/linux-64/libgfortran5-15.2.0-h68bc16d_19.conda sha256: 057978bb69fea29ed715a9b98adf71015c31baecc4aeb2bfc20d4fd5d83579d4 md5: 85072b0ad177c966294f129b7c04a2d5 @@ -10848,6 +11426,20 @@ packages: run_exports: {} size: 2483673 timestamp: 1778269025089 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libgfortran5-16.1.0-h79bb938_1.conda + sha256: 05078ab464d506dff971860cb1a553b35bc27c0b5ce8ec29b8bfaca5f2359652 + md5: dd51ed33e8c70995f8e33cc9dc537297 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=16.1.0 + constrains: + - libgfortran 16.1.0 + license: GPL-3.0-only WITH GCC-exception-3.1 + license_family: GPL + purls: [] + run_exports: {} + size: 2538696 + timestamp: 1785375448623 - conda: https://conda.anaconda.org/conda-forge/linux-64/libgl-1.7.0-ha4b6fd6_2.conda sha256: dc2752241fa3d9e40ce552c1942d0a4b5eeb93740c9723873f6fcf8d39ef8d2d md5: 928b8be80851f5d8ffb016f9c81dae7a @@ -10868,6 +11460,7 @@ packages: - libglx 1.7.0 ha4b6fd6_3 license: LicenseRef-libglvnd purls: [] + run_exports: {} size: 133469 timestamp: 1779728207669 - conda: https://conda.anaconda.org/conda-forge/linux-64/libgl-devel-1.7.0-ha4b6fd6_2.conda @@ -10927,6 +11520,25 @@ packages: - libglib >=2.88.2,<3.0a0 size: 4754220 timestamp: 1782463895250 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libglib-2.88.3-h0d30a3d_0.conda + sha256: 69ea4df61403531e5b3e5f3d52ba2837423df361b4eb8727f5b63aaf6de6768a + md5: 17c3b7b6bcbd35b688c933eb4834c0bc + depends: + - libgcc >=14 + - __glibc >=2.17,<3.0.a0 + - libiconv >=1.18,<2.0a0 + - libzlib >=1.3.2,<2.0a0 + - pcre2 >=10.47,<10.48.0a0 + - libffi >=3.5.2,<3.6.0a0 + constrains: + - glib >2.66 + license: LGPL-2.1-or-later + purls: [] + run_exports: + weak: + - libglib >=2.88.3,<3.0a0 + size: 4755324 + timestamp: 1785442107463 - conda: https://conda.anaconda.org/conda-forge/linux-64/libglu-9.0.3-h5888daf_1.conda sha256: a0105eb88f76073bbb30169312e797ed5449ebb4e964a756104d6e54633d17ef md5: 8422fcc9e5e172c91e99aef703b3ce65 @@ -10955,6 +11567,7 @@ packages: - __glibc >=2.17,<3.0.a0 license: LicenseRef-libglvnd purls: [] + run_exports: {} size: 133586 timestamp: 1779728183422 - conda: https://conda.anaconda.org/conda-forge/linux-64/libglx-1.7.0-ha4b6fd6_2.conda @@ -10977,6 +11590,7 @@ packages: - xorg-libx11 >=1.8.13,<2.0a0 license: LicenseRef-libglvnd purls: [] + run_exports: {} size: 76586 timestamp: 1779728199059 - conda: https://conda.anaconda.org/conda-forge/linux-64/libglx-devel-1.7.0-ha4b6fd6_2.conda @@ -11034,6 +11648,8 @@ packages: depends: - __glibc >=2.17,<3.0.a0 license: GPL-3.0-only WITH GCC-exception-3.1 + license_family: GPL + purls: [] run_exports: strong: - _openmp_mutex >=4.5 @@ -11465,6 +12081,27 @@ packages: run_exports: {} size: 1297668 timestamp: 1782800580119 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libharfbuzz-14.3.0-h17a8019_0.conda + sha256: fb72d6f7e90d4927cb53a4e13592559ce74b65052323d5d6dd12499b026c680e + md5: f33637ebded146eafa6b2197c8f597a6 + depends: + - __glibc >=2.17,<3.0.a0 + - cairo >=1.18.4,<2.0a0 + - graphite2 >=1.3.15,<2.0a0 + - icu >=78.3,<79.0a0 + - libfreetype >=2.14.3 + - libfreetype6 >=2.14.3 + - libgcc >=14 + - libglib >=2.88.3,<3.0a0 + - libpng >=1.6.58,<1.7.0a0 + - libstdcxx >=14 + - libzlib >=1.3.2,<2.0a0 + license: MIT + license_family: MIT + purls: [] + run_exports: {} + size: 1333436 + timestamp: 1785770053613 - conda: https://conda.anaconda.org/conda-forge/linux-64/libharfbuzz-devel-14.2.1-h17a8019_1.conda sha256: 6d8e793042affd18ca1784b7794fab14d16f54f984777ce6ab86bacaa465ab0d md5: 99cf21100441e51272f1cd6fe0632a20 @@ -11490,6 +12127,31 @@ packages: - libharfbuzz >=14.2.1 size: 1970690 timestamp: 1782800603897 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libharfbuzz-devel-14.3.0-h17a8019_0.conda + sha256: 7ce9326c2520ae758f523ae2d72a0762f7494d77fe8cc9a96065df541e1db8e2 + md5: 15634b3c7e5a68ca7c6e0bbf84cb2383 + depends: + - __glibc >=2.17,<3.0.a0 + - cairo >=1.18.4,<2.0a0 + - freetype + - graphite2 >=1.3.15,<2.0a0 + - icu >=78.3,<79.0a0 + - libfreetype >=2.14.3 + - libfreetype6 >=2.14.3 + - libgcc >=14 + - libglib >=2.88.3,<3.0a0 + - libharfbuzz 14.3.0 h17a8019_0 + - libpng >=1.6.58,<1.7.0a0 + - libstdcxx >=14 + - libzlib >=1.3.2,<2.0a0 + license: MIT + license_family: MIT + purls: [] + run_exports: + weak: + - libharfbuzz >=14.3.0 + size: 2081226 + timestamp: 1785770079548 - conda: https://conda.anaconda.org/conda-forge/linux-64/libhwloc-2.13.0-default_he001693_1000.conda sha256: 5041d295813dfb84652557839825880aae296222ab725972285c5abe3b6e4288 md5: c197985b58bc813d26b42881f0021c82 @@ -11518,6 +12180,20 @@ packages: purls: [] size: 1435782 timestamp: 1776989559668 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libhwy-1.4.0-h3a9caae_0.conda + sha256: 02ab5e50c3921e88ad4dd0bc8f3fe282d2d7d03a20203d3281954b94641deb3d + md5: 9544a7225c8366ea2c397aad5fb53470 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - libstdcxx >=14 + license: Apache-2.0 OR BSD-3-Clause + purls: [] + run_exports: + weak: + - libhwy >=1.4.0,<1.5.0a0 + size: 1431901 + timestamp: 1784325535334 - conda: https://conda.anaconda.org/conda-forge/linux-64/libiconv-1.18-h3b78370_2.conda sha256: c467851a7312765447155e071752d7bf9bf44d610a5687e32706f480aad2833f md5: 915f5995e94f60e9a4826e0b0920ee88 @@ -11573,6 +12249,21 @@ packages: - libjpeg-turbo >=3.2.0,<4.0a0 size: 652868 timestamp: 1783731886811 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libjpeg-turbo-3.2.0-hb03c661_1.conda + sha256: bba8538e6538ed58a8479b332337b96986561f975d06cfa2039a016c2d246ee4 + md5: 898d1c9793eaa52efc4727bd84d2e39a + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + constrains: + - jpeg <0.0.0a + license: IJG AND BSD-3-Clause AND Zlib + purls: [] + run_exports: + weak: + - libjpeg-turbo >=3.2.0,<4.0a0 + size: 650434 + timestamp: 1785896381946 - conda: https://conda.anaconda.org/conda-forge/linux-64/libjxl-0.11.2-h174a0a3_1.conda sha256: 0c8a78c6a42a6e4c6de3a5e82d692f60400d43f4cc80591745f28b37daad9c70 md5: 850f48943d6b4589800a303f0de6a816 @@ -11588,6 +12279,24 @@ packages: purls: [] size: 1846962 timestamp: 1777065125966 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libjxl-0.12.0-h174a0a3_1.conda + sha256: 1811d6c6558fbfe89326616c207cc7584032b60bc6f4329d2a76b961e2936a15 + md5: 1fff55640e1f12d7606965915be37bbb + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - libstdcxx >=14 + - libhwy >=1.4.0,<1.5.0a0 + - libbrotlienc >=1.2.0,<1.3.0a0 + - libbrotlidec >=1.2.0,<1.3.0a0 + license: BSD-3-Clause + license_family: BSD + purls: [] + run_exports: + weak: + - libjxl >=0.12.0,<0.13.0a0 + size: 1849836 + timestamp: 1783146019356 - conda: https://conda.anaconda.org/conda-forge/linux-64/libklu-2.3.5-h95ff59c_7100101.conda sha256: 6b4d462642c240dc3671af74f7705b23f34eea0f71e0d9dbcf14b4ed008311ff md5: efaa5e7dc6989363585fbb591480b256 @@ -11920,6 +12629,9 @@ packages: license: BSD-3-Clause license_family: BSD purls: [] + run_exports: + weak: + - libogg >=1.3.5,<1.4.0a0 size: 218500 timestamp: 1745825989535 - conda: https://conda.anaconda.org/conda-forge/linux-64/libopenblas-0.3.33-pthreads_h94d23a6_0.conda @@ -12085,6 +12797,23 @@ packages: - libopenvino >=2026.2.0,<2026.2.1.0a0 size: 6826433 timestamp: 1781798761467 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libopenvino-2026.2.1-h1f0fae8_1.conda + sha256: 7941fb9ba8c3a5a0a2401dc4120e8fcb561b96d928c43374eb93f545019a2858 + md5: ea41753f926f73966629d81fdf20ec6f + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - libstdcxx >=14 + - pugixml >=1.15,<1.16.0a0 + - tbb >=2023.0.0 + license: Apache-2.0 + license_family: APACHE + purls: [] + run_exports: + weak: + - libopenvino >=2026.2.1,<2026.2.2.0a0 + size: 6823841 + timestamp: 1782219077259 - conda: https://conda.anaconda.org/conda-forge/linux-64/libopenvino-auto-batch-plugin-2026.2.0-h7e124b3_1.conda sha256: 86c6157aa1718f24b1fcc07f7241cbc8df8c30e881dfd554c1999bd888855262 md5: 12019449d82d7d3d54b369a99be714ba @@ -12100,6 +12829,21 @@ packages: run_exports: {} size: 114553 timestamp: 1781798782628 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libopenvino-auto-batch-plugin-2026.2.1-h7e124b3_1.conda + sha256: 3ac14d36fa890840ae8474b8a9f0a094b8542fd8fbc409faf3d465c68f20aff0 + md5: 5698a64698e14e8a2e9e16f8f0de0e2e + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - libopenvino 2026.2.1 h1f0fae8_1 + - libstdcxx >=14 + - tbb >=2023.0.0 + license: Apache-2.0 + license_family: APACHE + purls: [] + run_exports: {} + size: 114628 + timestamp: 1782219097820 - conda: https://conda.anaconda.org/conda-forge/linux-64/libopenvino-auto-plugin-2026.2.0-h7e124b3_1.conda sha256: 46d53912d41a08f26e2cd856cda93ae6e54e5b9a230385075d0c7ad2f4a3ee80 md5: 422d5938f038515a7d084ab4446edbbb @@ -12115,6 +12859,21 @@ packages: run_exports: {} size: 250964 timestamp: 1781798795949 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libopenvino-auto-plugin-2026.2.1-h7e124b3_1.conda + sha256: 499a472fc7b598ad3753b8f2afe60eb5a277d48eca9362e8aca094b2862587a7 + md5: 2ce088ef09292930d4cb3262ce7e144d + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - libopenvino 2026.2.1 h1f0fae8_1 + - libstdcxx >=14 + - tbb >=2023.0.0 + license: Apache-2.0 + license_family: APACHE + purls: [] + run_exports: {} + size: 250912 + timestamp: 1782219111223 - conda: https://conda.anaconda.org/conda-forge/linux-64/libopenvino-hetero-plugin-2026.2.0-hd41364c_1.conda sha256: 4e5b545b05cb2d95c98e760d66243f68cde8029877ed8ac8f62e14e121593353 md5: aa76687726f31ab951afb9968fb352b7 @@ -12130,6 +12889,21 @@ packages: run_exports: {} size: 215501 timestamp: 1781798807667 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libopenvino-hetero-plugin-2026.2.1-hd41364c_1.conda + sha256: bec24379598a4405de171ad151945e79743c6bd049aceabf190b753c3f7a11da + md5: 02e71250f7ca786c4b183d0a39ef63ab + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - libopenvino 2026.2.1 h1f0fae8_1 + - libstdcxx >=14 + - pugixml >=1.15,<1.16.0a0 + license: Apache-2.0 + license_family: APACHE + purls: [] + run_exports: {} + size: 215488 + timestamp: 1782219123433 - conda: https://conda.anaconda.org/conda-forge/linux-64/libopenvino-intel-cpu-plugin-2026.2.0-h1f0fae8_1.conda sha256: 7e485478d665279a6f278132e8cec3c501183be7d56186c7c60b3875483c1fb7 md5: 1b11e6141c37c0aa7077d8afed643288 @@ -12146,6 +12920,22 @@ packages: run_exports: {} size: 13647258 timestamp: 1781798819601 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libopenvino-intel-cpu-plugin-2026.2.1-h1f0fae8_1.conda + sha256: eecc040a7838752a2dff9b4435a4c59bbc67b83e0c880457935b968206cb20b5 + md5: 7288f979a74cfe3fd4b32d8a0dc7baa4 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - libopenvino 2026.2.1 h1f0fae8_1 + - libstdcxx >=14 + - pugixml >=1.15,<1.16.0a0 + - tbb >=2023.0.0 + license: Apache-2.0 + license_family: APACHE + purls: [] + run_exports: {} + size: 13637410 + timestamp: 1782219135415 - conda: https://conda.anaconda.org/conda-forge/linux-64/libopenvino-intel-gpu-plugin-2026.2.0-h1f0fae8_1.conda sha256: 57b50e34355e5aba5fb1d768c631238fcf3f7070ad725a68c10d1cbec56ea21c md5: 327b32c09092b8f28b4dbd6ebb71fa91 @@ -12163,6 +12953,23 @@ packages: run_exports: {} size: 12381073 timestamp: 1781798859383 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libopenvino-intel-gpu-plugin-2026.2.1-h1f0fae8_1.conda + sha256: a47442ce578b022e19a306f963536a108cc79385f4e09d57a14a849b6a864604 + md5: c0a258b12f0c18c476b8344dbd6db8d5 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - libopenvino 2026.2.1 h1f0fae8_1 + - libstdcxx >=14 + - ocl-icd >=2.3.4,<3.0a0 + - pugixml >=1.15,<1.16.0a0 + - tbb >=2023.0.0 + license: Apache-2.0 + license_family: APACHE + purls: [] + run_exports: {} + size: 12367381 + timestamp: 1782219178219 - conda: https://conda.anaconda.org/conda-forge/linux-64/libopenvino-intel-npu-plugin-2026.2.0-h1f0fae8_1.conda sha256: 9faaef86d0a0dbba58a6b7dc3677f0de155e6741b11534c591df4dd245989c2b md5: 16e1f1517166b8cb31055233b1224ab9 @@ -12180,6 +12987,23 @@ packages: run_exports: {} size: 2624019 timestamp: 1781798892881 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libopenvino-intel-npu-plugin-2026.2.1-h1f0fae8_1.conda + sha256: 45a91feb68ccce90ad0fa86520572233ca20be56deae0c920f86133d020ad1e8 + md5: c214b149e108e92672e0ee097ebe16f7 + depends: + - __glibc >=2.17,<3.0.a0 + - level-zero >=1.29.0,<2.0a0 + - libgcc >=14 + - libopenvino 2026.2.1 h1f0fae8_1 + - libstdcxx >=14 + - pugixml >=1.15,<1.16.0a0 + - tbb >=2023.0.0 + license: Apache-2.0 + license_family: APACHE + purls: [] + run_exports: {} + size: 2630818 + timestamp: 1782219217519 - conda: https://conda.anaconda.org/conda-forge/linux-64/libopenvino-ir-frontend-2026.2.0-hd41364c_1.conda sha256: e0165152cc633ae8e041ac732c51169959e27e48a981f11950bb21fd0274fecb md5: 7c21104f61dc46a6b8f3ce2b105e9881 @@ -12197,6 +13021,23 @@ packages: - libopenvino-ir-frontend >=2026.2.0,<2026.2.1.0a0 size: 202097 timestamp: 1781798907064 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libopenvino-ir-frontend-2026.2.1-hd41364c_1.conda + sha256: ebeba9a3ac9505ee69b556865b7d1b9fbbad01ca1ebe6a4249ff62c3dc677b47 + md5: 2d946aebcf06e9ba438880987050e975 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - libopenvino 2026.2.1 h1f0fae8_1 + - libstdcxx >=14 + - pugixml >=1.15,<1.16.0a0 + license: Apache-2.0 + license_family: APACHE + purls: [] + run_exports: + weak: + - libopenvino-ir-frontend >=2026.2.1,<2026.2.2.0a0 + size: 201061 + timestamp: 1782219232657 - conda: https://conda.anaconda.org/conda-forge/linux-64/libopenvino-onnx-frontend-2026.2.0-h7a07914_1.conda sha256: 081ee84091459766b2bd817430af901ff6efeddfd34ceaf0e9c04a07b32fb8a0 md5: bbf2c614a45b6115e7c1b04a1ff9ef07 @@ -12216,6 +13057,25 @@ packages: - libopenvino-onnx-frontend >=2026.2.0,<2026.2.1.0a0 size: 1944892 timestamp: 1781798920651 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libopenvino-onnx-frontend-2026.2.1-h607c73d_1.conda + sha256: 7b105c0102356352d6d9518a112ff6343dab6b8f32c837809117cd26cbf006df + md5: 3bd3599825189418ea14b2c9da3a6d87 + depends: + - __glibc >=2.17,<3.0.a0 + - libabseil * cxx17* + - libabseil >=20260526.0,<20260527.0a0 + - libgcc >=14 + - libopenvino 2026.2.1 h1f0fae8_1 + - libprotobuf >=7.35.1,<7.35.2.0a0 + - libstdcxx >=14 + license: Apache-2.0 + license_family: APACHE + purls: [] + run_exports: + weak: + - libopenvino-onnx-frontend >=2026.2.1,<2026.2.2.0a0 + size: 1944558 + timestamp: 1782219246849 - conda: https://conda.anaconda.org/conda-forge/linux-64/libopenvino-paddle-frontend-2026.2.0-h7a07914_1.conda sha256: cb3c1cefe5b07162fa5f42cac64a3b56d61378954ad3adc347b4044a493e2087 md5: 2cd295974c11e0e2f2947b3dd96cbe4b @@ -12235,6 +13095,25 @@ packages: - libopenvino-paddle-frontend >=2026.2.0,<2026.2.1.0a0 size: 691109 timestamp: 1781798934483 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libopenvino-paddle-frontend-2026.2.1-h607c73d_1.conda + sha256: af45c03d41ebe0b48c28b68be31ee919cb801ac5077164808a66db515ad6a316 + md5: 91e198085bff9d8fa02d4d947f026ba8 + depends: + - __glibc >=2.17,<3.0.a0 + - libabseil * cxx17* + - libabseil >=20260526.0,<20260527.0a0 + - libgcc >=14 + - libopenvino 2026.2.1 h1f0fae8_1 + - libprotobuf >=7.35.1,<7.35.2.0a0 + - libstdcxx >=14 + license: Apache-2.0 + license_family: APACHE + purls: [] + run_exports: + weak: + - libopenvino-paddle-frontend >=2026.2.1,<2026.2.2.0a0 + size: 690240 + timestamp: 1782219261154 - conda: https://conda.anaconda.org/conda-forge/linux-64/libopenvino-pytorch-frontend-2026.2.0-hecca717_1.conda sha256: 9944c66eb0cbffcb8cd737e1db2ca72ed34326f006bdedb204910895e0cf3539 md5: f7193f7d2ffb3645540675945fc05f2c @@ -12251,6 +13130,22 @@ packages: - libopenvino-pytorch-frontend >=2026.2.0,<2026.2.1.0a0 size: 1226248 timestamp: 1781798946751 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libopenvino-pytorch-frontend-2026.2.1-hecca717_1.conda + sha256: e6353874a36143ffb7db7ec2c3767fd5e3434a8eeff41a569bc46e68259f668f + md5: 152d6694f1d05b53319b8376cdd811e4 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - libopenvino 2026.2.1 h1f0fae8_1 + - libstdcxx >=14 + license: Apache-2.0 + license_family: APACHE + purls: [] + run_exports: + weak: + - libopenvino-pytorch-frontend >=2026.2.1,<2026.2.2.0a0 + size: 1226625 + timestamp: 1782219274006 - conda: https://conda.anaconda.org/conda-forge/linux-64/libopenvino-tensorflow-frontend-2026.2.0-h78e8023_1.conda sha256: 2b2bc9ef6dfde1752bf363e463b6b0020c7674c98b6c1aed0a551ac2ea5ac494 md5: 78008823be2f1e1a3ef286ce2ef9c032 @@ -12271,6 +13166,26 @@ packages: - libopenvino-tensorflow-frontend >=2026.2.0,<2026.2.1.0a0 size: 1283246 timestamp: 1781798960009 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libopenvino-tensorflow-frontend-2026.2.1-h21c0c73_1.conda + sha256: cffe112815b8eb57528fdfdf8b39f6a0915884291147dab5bc2066d2bf123031 + md5: 89d2455ec2f065786856b0cd2ac1c0c6 + depends: + - __glibc >=2.17,<3.0.a0 + - libabseil * cxx17* + - libabseil >=20260526.0,<20260527.0a0 + - libgcc >=14 + - libopenvino 2026.2.1 h1f0fae8_1 + - libprotobuf >=7.35.1,<7.35.2.0a0 + - libstdcxx >=14 + - snappy >=1.2.2,<1.3.0a0 + license: Apache-2.0 + license_family: APACHE + purls: [] + run_exports: + weak: + - libopenvino-tensorflow-frontend >=2026.2.1,<2026.2.2.0a0 + size: 1284650 + timestamp: 1782219287644 - conda: https://conda.anaconda.org/conda-forge/linux-64/libopenvino-tensorflow-lite-frontend-2026.2.0-hecca717_1.conda sha256: 5a2c5cabb91a241f624b7c99973664dea39013a4189a3700ff5f57e0901e0ef0 md5: 94b6ad5b5ca490fc6ab1ddbf65178a95 @@ -12287,6 +13202,22 @@ packages: - libopenvino-tensorflow-lite-frontend >=2026.2.0,<2026.2.1.0a0 size: 503112 timestamp: 1781798972815 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libopenvino-tensorflow-lite-frontend-2026.2.1-hecca717_1.conda + sha256: 142e7b24173ca8c32dbdb29c60f33a56ffb21a4ed733c9d6ab160c3a213ff52e + md5: c1a50f20847df0a8cb462138153ab46f + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - libopenvino 2026.2.1 h1f0fae8_1 + - libstdcxx >=14 + license: Apache-2.0 + license_family: APACHE + purls: [] + run_exports: + weak: + - libopenvino-tensorflow-lite-frontend >=2026.2.1,<2026.2.2.0a0 + size: 501906 + timestamp: 1782219300706 - conda: https://conda.anaconda.org/conda-forge/linux-64/libopus-1.6.1-h280c20c_0.conda sha256: f1061a26213b9653bbb8372bfa3f291787ca091a9a3060a10df4d5297aad74fd md5: 2446ac1fe030c2aa6141386c1f5a6aed @@ -12296,6 +13227,9 @@ packages: license: BSD-3-Clause license_family: BSD purls: [] + run_exports: + weak: + - libopus >=1.6.1,<2.0a0 size: 324993 timestamp: 1768497114401 - conda: https://conda.anaconda.org/conda-forge/linux-64/libparu-1.0.0-hc6afc67_7100101.conda @@ -12324,6 +13258,9 @@ packages: license: MIT license_family: MIT purls: [] + run_exports: + weak: + - libpciaccess >=0.19,<0.20.0a0 size: 29147 timestamp: 1773533027610 - conda: https://conda.anaconda.org/conda-forge/linux-64/libplacebo-7.360.1-h9eeb4b2_0.conda @@ -12344,6 +13281,24 @@ packages: - libplacebo >=7.360.1,<7.361.0a0 size: 549348 timestamp: 1777835950707 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libplacebo-7.360.1-hc50b9dd_1.conda + sha256: 7fa90c06b81559cb56ea7806a6696fb4902a1acc20bbaff1bd3a4a75b3ffa0d5 + md5: 4a750e2ae0d52d003bb1e3421581585e + depends: + - libstdcxx >=14 + - libgcc >=14 + - __glibc >=2.17,<3.0.a0 + - libdovi >=3.4.0,<4.0a0 + - lcms2 >=2.19.1,<3.0a0 + - shaderc >=2026.3,<2026.4.0a0 + - libvulkan-loader >=1.4.341.0,<2.0a0 + license: LGPL-2.1-or-later + purls: [] + run_exports: + weak: + - libplacebo >=7.360.1,<7.361.0a0 + size: 550759 + timestamp: 1784287829706 - conda: https://conda.anaconda.org/conda-forge/linux-64/libpng-1.6.58-h421ea60_0.conda sha256: 377cfe037f3eeb3b1bf3ad333f724a64d32f315ee1958581fc671891d63d3f89 md5: eba48a68a1a2b9d3c0d9511548db85db @@ -12402,6 +13357,7 @@ packages: - libzlib >=1.3.2,<2.0a0 license: BSD-3-Clause license_family: BSD + purls: [] run_exports: weak: - libprotobuf >=7.35.1,<7.35.2.0a0 @@ -12627,6 +13583,9 @@ packages: license: LGPL-2.1-or-later license_family: LGPL purls: [] + run_exports: + weak: + - libsndfile >=1.2.2,<1.3.0a0 size: 355619 timestamp: 1765181778282 - conda: https://conda.anaconda.org/conda-forge/linux-64/libsodium-1.0.22-h280c20c_1.conda @@ -12813,6 +13772,8 @@ packages: constrains: - libstdcxx-ng ==16.1.0=*_1 license: GPL-3.0-only WITH GCC-exception-3.1 + license_family: GPL + purls: [] run_exports: {} size: 6631744 timestamp: 1785375462643 @@ -12829,6 +13790,19 @@ packages: - libstdcxx size: 27776 timestamp: 1778269074600 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libstdcxx-ng-16.1.0-hdf11a46_1.conda + sha256: 2876ca4463d1b394eb969ce4a84d1620aa63fb8202a6397837d1e45ec76c1208 + md5: c94f06123272d8e129d4acf3a25ffb35 + depends: + - libstdcxx 16.1.0 h934c35e_1 + license: GPL-3.0-only WITH GCC-exception-3.1 + license_family: GPL + purls: [] + run_exports: + strong: + - libstdcxx + size: 28253 + timestamp: 1785375500257 - conda: https://conda.anaconda.org/conda-forge/linux-64/libsuitesparseconfig-7.10.1-h901830b_7100101.conda sha256: d8f32a0b0ee17fbace7af4bd34ad554cc855b9c18e0aeccf8395e1478c161f37 md5: 57ae1dd979da7aa88a9b38bfa2e1d6b2 @@ -12844,6 +13818,18 @@ packages: purls: [] size: 42708 timestamp: 1741963824815 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libsystemd0-257.13-h084b8d7_1.conda + sha256: 2293884d59cf0436c37fc0a4bad71011a8de2a6913610d1c701a7703377c1f75 + md5: ea0da9c20bbb221b530810c3c68bbe62 + depends: + - __glibc >=2.17,<3.0.a0 + - libcap >=2.78,<2.79.0a0 + - libgcc >=14 + license: LGPL-2.1-or-later + purls: [] + run_exports: {} + size: 493022 + timestamp: 1780084748140 - conda: https://conda.anaconda.org/conda-forge/linux-64/libsystemd0-257.13-hd0affe5_0.conda sha256: c5008b602cb5c819f7b52d418b3ed17e1818cbbf6705b189e7ab36bb70cce3d8 md5: 8ee3cb7f64be0e8c4787f3a4dbe024e6 @@ -12954,6 +13940,18 @@ packages: - libtorch >=2.12.1,<2.13.0a0 size: 61888662 timestamp: 1781832531047 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libudev1-257.13-h084b8d7_1.conda + sha256: 287d05680e49eea51b8145fbf34bc213c0618b04f32e450e9da5d715e5134e38 + md5: 89e5671a076d99516a6acd72a35b1640 + depends: + - __glibc >=2.17,<3.0.a0 + - libcap >=2.78,<2.79.0a0 + - libgcc >=14 + license: LGPL-2.1-or-later + purls: [] + run_exports: {} + size: 145969 + timestamp: 1780084753104 - conda: https://conda.anaconda.org/conda-forge/linux-64/libudev1-257.13-hd0affe5_0.conda sha256: 1a1e367c04d66030aa93b4d33905f7f6fbb59cfc292e816fe3e9c1e8b3f4d1e2 md5: 2c2270f93d6f9073cbf72d821dfc7d72 @@ -12999,6 +13997,9 @@ packages: license: MIT license_family: MIT purls: [] + run_exports: + weak: + - libunwind >=1.8.3,<1.9.0a0 size: 75995 timestamp: 1757032240102 - conda: https://conda.anaconda.org/conda-forge/linux-64/liburcu-0.14.0-hac33072_0.conda @@ -13021,6 +14022,9 @@ packages: license: MIT license_family: MIT purls: [] + run_exports: + weak: + - liburing >=2.14,<2.15.0a0 size: 154203 timestamp: 1770566529700 - conda: https://conda.anaconda.org/conda-forge/linux-64/libusb-1.0.29-h73b1eb8_0.conda @@ -13032,6 +14036,9 @@ packages: - libudev1 >=257.4 license: LGPL-2.1-or-later purls: [] + run_exports: + weak: + - libusb >=1.0.29,<2.0a0 size: 89551 timestamp: 1748856210075 - conda: https://conda.anaconda.org/conda-forge/linux-64/libuuid-2.42.1-h5347b49_0.conda @@ -13094,6 +14101,30 @@ packages: purls: [] size: 221308 timestamp: 1765652453244 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libva-2.24.1-he1eb515_0.conda + sha256: 16a76abbb4fd1de4516ac4a3d06cbf1f561bc8049ca72b04dcac395eee74d017 + md5: eb1b7f8bfdea40eef150c4a1d37df09e + depends: + - __glibc >=2.17,<3.0.a0 + - libdrm >=2.4.127,<2.5.0a0 + - libegl >=1.7.0,<2.0a0 + - libgcc >=14 + - libgl >=1.7.0,<2.0a0 + - libglx >=1.7.0,<2.0a0 + - libxcb >=1.17.0,<2.0a0 + - wayland >=1.25.0,<2.0a0 + - wayland-protocols + - xorg-libx11 >=1.8.13,<2.0a0 + - xorg-libxext >=1.3.7,<2.0a0 + - xorg-libxfixes >=6.0.2,<7.0a0 + license: MIT + license_family: MIT + purls: [] + run_exports: + weak: + - libva >=2.24.1,<3.0a0 + size: 222717 + timestamp: 1783519315031 - conda: https://conda.anaconda.org/conda-forge/linux-64/libvorbis-1.3.7-h54a6638_2.conda sha256: ca494c99c7e5ecc1b4cd2f72b5584cef3d4ce631d23511184411abcbb90a21a5 md5: b4ecbefe517ed0157c37f8182768271c @@ -13107,6 +14138,9 @@ packages: license: BSD-3-Clause license_family: BSD purls: [] + run_exports: + weak: + - libvorbis >=1.3.7,<1.4.0a0 size: 285894 timestamp: 1753879378005 - conda: https://conda.anaconda.org/conda-forge/linux-64/libvpl-2.16.0-h54a6638_0.conda @@ -13136,6 +14170,9 @@ packages: license: BSD-3-Clause license_family: BSD purls: [] + run_exports: + weak: + - libvpx >=1.15.2,<1.16.0a0 size: 1070048 timestamp: 1762010217363 - conda: https://conda.anaconda.org/conda-forge/linux-64/libvulkan-headers-1.4.341.0-h171cf75_0.conda @@ -13165,6 +14202,25 @@ packages: purls: [] size: 199795 timestamp: 1770077125520 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libvulkan-loader-1.4.357.0-h5279c79_0.conda + sha256: 1e30138cff1e6ba5739ce3ec787b24ef22ac6e2008d8a2073df334e9e5f8690b + md5: 9d8c72f797f6f2d1c32897603d70824c + depends: + - __glibc >=2.17,<3.0.a0 + - libstdcxx >=14 + - libgcc >=14 + - xorg-libx11 >=1.8.13,<2.0a0 + - xorg-libxrandr >=1.5.5,<2.0a0 + constrains: + - libvulkan-headers 1.4.357.0.* + license: Apache-2.0 + license_family: APACHE + purls: [] + run_exports: + weak: + - libvulkan-loader >=1.4.357.0,<2.0a0 + size: 203456 + timestamp: 1785311377294 - conda: https://conda.anaconda.org/conda-forge/linux-64/libwebp-1.6.0-h9635ea4_0.conda sha256: 6ebd63ad14a601d715e5812c062e6c0c7a1fe9e9acacd8bd103de00a492f7b5f md5: 2a4575ed55e0a4346722aac07ccd2b23 @@ -13227,6 +14283,19 @@ packages: - libxcrypt >=4.4.36 size: 100393 timestamp: 1702724383534 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libxcrypt-4.4.38-h280c20c_0.conda + sha256: f7e9292dd219a6435bbb1223da9586c3e70d66d169c5a92f08db3f2127df04e9 + md5: f7a7ff5a6ab331e037abd34f379a631d + depends: + - libgcc >=14 + - __glibc >=2.17,<3.0.a0 + license: LGPL-2.1-or-later + purls: [] + run_exports: + weak: + - libxcrypt >=4.4.38 + size: 101957 + timestamp: 1785887123445 - conda: https://conda.anaconda.org/conda-forge/linux-64/libxkbcommon-1.13.1-hca5e8e5_0.conda sha256: d2195b5fbcb0af1ff7b345efdf89290c279b8d1d74f325ae0ac98148c375863c md5: 2bca1fbb221d9c3c8e3a155784bbc2e9 @@ -13259,6 +14328,9 @@ packages: license: MIT/X11 Derivative license_family: MIT purls: [] + run_exports: + weak: + - libxkbcommon >=1.13.2,<2.0a0 size: 851166 timestamp: 1780213397575 - conda: https://conda.anaconda.org/conda-forge/linux-64/libxkbfile-1.2.0-hb03c661_0.conda @@ -13385,6 +14457,7 @@ packages: - zlib 1.3.2 *_3 license: Zlib license_family: Other + purls: [] run_exports: weak: - libzlib >=1.3.2,<2.0a0 @@ -13725,6 +14798,9 @@ packages: license: LGPL-2.1-only license_family: LGPL purls: [] + run_exports: + weak: + - mpg123 >=1.32.9,<1.33.0a0 size: 491140 timestamp: 1730581373280 - conda: https://conda.anaconda.org/conda-forge/linux-64/msgpack-python-1.1.2-py312hd9148b4_1.conda @@ -14122,6 +15198,7 @@ packages: license: Apache-2.0 license_family: APACHE purls: [] + run_exports: {} size: 55754 timestamp: 1773844383536 - conda: https://conda.anaconda.org/conda-forge/linux-64/openexr-3.4.13-h6de6307_2.conda @@ -14161,6 +15238,21 @@ packages: - openexr >=3.4.13,<3.5.0a0 size: 1223929 timestamp: 1782198396418 +- conda: https://conda.anaconda.org/conda-forge/linux-64/openh264-2.6.0-h65dd3cf_1.conda + sha256: 5317c5c23762f3fe1c8510565a2bb94c645e1470ff73b386315656404f7eb58a + md5: 69894a95220a17a66272daa701c387bc + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - libstdcxx >=14 + license: BSD-2-Clause + license_family: BSD + purls: [] + run_exports: + weak: + - openh264 >=2.6.0,<2.6.1.0a0 + size: 726478 + timestamp: 1782685945856 - conda: https://conda.anaconda.org/conda-forge/linux-64/openh264-2.6.0-hc22cd8d_0.conda sha256: 3f231f2747a37a58471c82a9a8a80d92b7fece9f3fce10901a5ac888ce00b747 md5: b28cf020fd2dead0ca6d113608683842 @@ -14293,6 +15385,21 @@ packages: - openssl >=3.6.3,<4.0a0 size: 3159683 timestamp: 1781069855778 +- conda: https://conda.anaconda.org/conda-forge/linux-64/openssl-3.6.3-h35e630c_1.conda + sha256: 012096056b97abf1f68c46b7146bd2cbd68c1be762340b4f5dad4fbbe99177bc + md5: c5955c27917ff2234def47f075e71e02 + depends: + - __glibc >=2.17,<3.0.a0 + - ca-certificates + - libgcc >=14 + license: Apache-2.0 + license_family: Apache + purls: [] + run_exports: + weak: + - openssl >=3.6.3,<4.0a0 + size: 3182423 + timestamp: 1785913583650 - conda: https://conda.anaconda.org/conda-forge/linux-64/openvdb-13.0.0-py312he8ce4a6_3.conda sha256: 3d41323cb6fedc6dd4a0010101ddf3dc2a362ff6c58a47eaddb90b6683fa994f md5: ec455ee600cd5e5057ad5b3b519b8a53 @@ -14398,6 +15505,30 @@ packages: purls: [] size: 458036 timestamp: 1774281947855 +- conda: https://conda.anaconda.org/conda-forge/linux-64/pango-1.58.0-hda50119_0.conda + sha256: ec020fd570ba116c06592af8d8ce05f41c20b6d673d1a8a7a5a98fecad0f4b14 + md5: ef685e254006246695cbc36cf3fda159 + depends: + - __glibc >=2.17,<3.0.a0 + - cairo >=1.18.4,<2.0a0 + - fontconfig >=2.18.2,<3.0a0 + - fonts-conda-ecosystem + - fribidi >=1.0.16,<2.0a0 + - libexpat >=2.8.1,<3.0a0 + - libfreetype >=2.14.3 + - libfreetype6 >=2.14.3 + - libgcc >=14 + - libglib >=2.88.2,<3.0a0 + - libharfbuzz >=14.2.1 + - libpng >=1.6.58,<1.7.0a0 + - libzlib >=1.3.2,<2.0a0 + license: LGPL-2.1-or-later + purls: [] + run_exports: + weak: + - pango >=1.58.0,<2.0a0 + size: 468909 + timestamp: 1785174647353 - conda: https://conda.anaconda.org/conda-forge/linux-64/pcl-1.15.1-h1259f1f_14.conda sha256: 9c890fbfb48000ec404540872a5e9c847104fd0f72cc8cd6efbc6aa1bfb94108 md5: e147c64c0a4f74740eb23b5d9cb5731a @@ -14449,6 +15580,9 @@ packages: license: BSD-3-Clause license_family: BSD purls: [] + run_exports: + weak: + - pcre2 >=10.47,<10.48.0a0 size: 1222481 timestamp: 1763655398280 - conda: https://conda.anaconda.org/conda-forge/linux-64/pillow-12.2.0-py312h50c33e8_0.conda @@ -14533,6 +15667,21 @@ packages: purls: [] size: 450960 timestamp: 1754665235234 +- conda: https://conda.anaconda.org/conda-forge/linux-64/pixman-0.46.4-h54a6638_2.conda + sha256: 9e5b5be056820ade8b09ef73cf9f4bea037eb9887145d0297ac99e0add88878d + md5: 7cd77fef4da3e1ca9484394616cb71f1 + depends: + - libstdcxx >=14 + - libgcc >=14 + - __glibc >=2.17,<3.0.a0 + license: MIT + license_family: MIT + purls: [] + run_exports: + weak: + - pixman >=0.46.4,<1.0a0 + size: 376220 + timestamp: 1784286827180 - conda: https://conda.anaconda.org/conda-forge/linux-64/pkg-config-0.29.2-h4bc722e_1009.conda sha256: c9601efb1af5391317e04eca77c6fe4d716bf1ca1ad8da2a05d15cb7c28d7d4e md5: 1bee70681f504ea424fb07cdb090c001 @@ -14749,6 +15898,9 @@ packages: license: MIT license_family: MIT purls: [] + run_exports: + weak: + - pugixml >=1.15,<1.16.0a0 size: 118488 timestamp: 1736601364156 - conda: https://conda.anaconda.org/conda-forge/linux-64/pulseaudio-client-17.0-h9a6aba3_3.conda @@ -14768,6 +15920,9 @@ packages: license: LGPL-2.1-or-later license_family: LGPL purls: [] + run_exports: + weak: + - pulseaudio-client >=17.0,<17.1.0a0 size: 750785 timestamp: 1763148198088 - conda: https://conda.anaconda.org/conda-forge/linux-64/py-opencv-4.13.0-qt6_py312h2638c08_610.conda @@ -15536,6 +16691,9 @@ packages: - libegl >=1.7.0,<2.0a0 license: Zlib purls: [] + run_exports: + weak: + - sdl2 >=2.32.56,<3.0a0 size: 589145 timestamp: 1757842881000 - conda: https://conda.anaconda.org/conda-forge/linux-64/sdl3-3.4.10-hdeec2a5_0.conda @@ -15568,6 +16726,39 @@ packages: purls: [] size: 2148830 timestamp: 1780262823658 +- conda: https://conda.anaconda.org/conda-forge/linux-64/sdl3-3.4.14-hdeec2a5_0.conda + sha256: b7f4a338074d0daa5086d6d7f319dd79b277c47a761abd8ebac72c0253f4c6ad + md5: 1ef39a7b42a06e262723fa7937210639 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - libstdcxx >=14 + - libvulkan-loader >=1.4.357.0,<2.0a0 + - liburing >=2.14,<2.15.0a0 + - libudev1 >=257.13 + - xorg-libxcursor >=1.2.3,<2.0a0 + - xorg-libxtst >=1.2.5,<2.0a0 + - dbus >=1.16.2,<2.0a0 + - libunwind >=1.8.3,<1.9.0a0 + - libusb >=1.0.29,<2.0a0 + - libegl >=1.7.0,<2.0a0 + - pulseaudio-client >=17.0,<17.1.0a0 + - libgl >=1.7.0,<2.0a0 + - xorg-libxext >=1.3.7,<2.0a0 + - xorg-libxfixes >=6.0.2,<7.0a0 + - libdrm >=2.4.127,<2.5.0a0 + - xorg-libxi >=1.8.3,<2.0a0 + - wayland >=1.26.0,<2.0a0 + - xorg-libxscrnsaver >=1.2.4,<2.0a0 + - libxkbcommon >=1.13.2,<2.0a0 + - xorg-libx11 >=1.8.13,<2.0a0 + license: Zlib + purls: [] + run_exports: + weak: + - sdl3 >=3.4.14,<4.0a0 + size: 2158268 + timestamp: 1785816103164 - conda: https://conda.anaconda.org/conda-forge/linux-64/sdl3-3.4.8-hdeec2a5_0.conda sha256: bec327fffc08369afe4c1384a5b50cac9b38e7af42ebdf5f89628633bd980dbd md5: 508bad511e617479f0ad60cc49fba903 @@ -15615,6 +16806,23 @@ packages: - shaderc >=2026.2,<2026.3.0a0 size: 113684 timestamp: 1777360595361 +- conda: https://conda.anaconda.org/conda-forge/linux-64/shaderc-2026.3-h1b60276_0.conda + sha256: 1325456e9cff1ec8a5e826f64b8eef5806162bfcceeed2e32817a3c280f0dfc9 + md5: ee5e719bbf258faa3b6533a1a621092b + depends: + - __glibc >=2.17,<3.0.a0 + - glslang >=16,<17.0a0 + - libgcc >=14 + - libstdcxx >=14 + - spirv-tools >=2026,<2027.0a0 + license: Apache-2.0 + license_family: Apache + purls: [] + run_exports: + weak: + - shaderc >=2026.3,<2026.4.0a0 + size: 114267 + timestamp: 1784251192959 - conda: https://conda.anaconda.org/conda-forge/linux-64/sip-6.10.0-py312h1289d80_1.conda sha256: 65224ec231bb938a720897d75fb76f20a2376bded01a438f5220f6fa43195e4f md5: f96baa9ba899d5d30578675fe28b3473 @@ -15659,6 +16867,9 @@ packages: license: BSD-3-Clause license_family: BSD purls: [] + run_exports: + weak: + - snappy >=1.2.2,<1.3.0a0 size: 45829 timestamp: 1762948049098 - conda: https://conda.anaconda.org/conda-forge/linux-64/spdlog-1.17.0-hab81395_1.conda @@ -15702,6 +16913,23 @@ packages: purls: [] size: 2392190 timestamp: 1780139567779 +- conda: https://conda.anaconda.org/conda-forge/linux-64/spirv-tools-2026.2-hb700be7_3.conda + sha256: 94e64d435ca9649b74188c0072033193fda5a2b1f7603ee9d136130e5cd15e9c + md5: c402e3603c22e3fa9f3101a703a041d0 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - libstdcxx >=14 + constrains: + - spirv-headers >=1.4.357.0,<1.4.357.1.0a0 + license: Apache-2.0 + license_family: APACHE + purls: [] + run_exports: + weak: + - spirv-tools >=2026,<2027.0a0 + size: 2405017 + timestamp: 1785688937831 - conda: https://conda.anaconda.org/conda-forge/linux-64/sqlite-3.53.2-hbc0de68_0.conda sha256: b7c3217b437f8aa531b4a18c89dc137b6066757f1e93146dc0d8d999ef55da09 md5: 38d9bf35a4cc83094a327811e548b660 @@ -15769,6 +16997,21 @@ packages: purls: [] size: 2619743 timestamp: 1769664536467 +- conda: https://conda.anaconda.org/conda-forge/linux-64/svt-av1-4.2.0-hecca717_0.conda + sha256: c79f983a6bb4218bdef9064aec5821d59d744f9a98f8cf8437c7bd0351df0d95 + md5: 683f1b6d013bb1eb0d5c8025d2eb21a3 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - libstdcxx >=14 + license: BSD-2-Clause + license_family: BSD + purls: [] + run_exports: + weak: + - svt-av1 >=4.2.0,<4.2.1.0a0 + size: 2666786 + timestamp: 1784069888521 - conda: https://conda.anaconda.org/conda-forge/linux-64/swig-4.4.1-h7a96c5f_0.conda sha256: 45ec1eedd1de2d7985955290015773a4adc9b8ea95d0f839aaabda2ed075d83c md5: ce50bd18ea2a92833be8b62881929e23 @@ -16280,6 +17523,23 @@ packages: purls: [] size: 334139 timestamp: 1773959575393 +- conda: https://conda.anaconda.org/conda-forge/linux-64/wayland-1.26.0-hd6090a7_0.conda + sha256: 6b9e182021ef3a64ab3bf788ebdab6de6775035612237c639ccafc941639eb13 + md5: b34c5559f45d8996e3bc0b6250a6cc84 + depends: + - __glibc >=2.17,<3.0.a0 + - libexpat >=2.8.1,<3.0a0 + - libffi >=3.5.2,<3.6.0a0 + - libgcc >=14 + - libstdcxx >=14 + license: MIT + license_family: MIT + purls: [] + run_exports: + weak: + - wayland >=1.26.0,<2.0a0 + size: 340543 + timestamp: 1784249169392 - conda: https://conda.anaconda.org/conda-forge/linux-64/websockets-15.0.1-py312h5253ce2_2.conda sha256: 550e082eb189cf1a6dea57e544259152704759524f373ba2bd773cb8214a0c23 md5: 3fed1ea2c74091df72ad4e893e55c905 @@ -16317,6 +17577,9 @@ packages: license: GPL-2.0-or-later license_family: GPL purls: [] + run_exports: + weak: + - x264 >=1!164.3095,<1!165 size: 897548 timestamp: 1660323080555 - conda: https://conda.anaconda.org/conda-forge/linux-64/x265-3.5-h924138e_3.tar.bz2 @@ -16328,6 +17591,9 @@ packages: license: GPL-2.0-or-later license_family: GPL purls: [] + run_exports: + weak: + - x265 >=3.5,<3.6.0a0 size: 3357188 timestamp: 1646609687141 - conda: https://conda.anaconda.org/conda-forge/linux-64/xcb-util-0.4.1-h4f16b4b_2.conda @@ -16428,6 +17694,19 @@ packages: purls: [] size: 399291 timestamp: 1772021302485 +- conda: https://conda.anaconda.org/conda-forge/linux-64/xkeyboard-config-2.48-h280c20c_0.conda + sha256: 3b04afd5d1a65d2d27ac2d49a63b01ab8bcd875776779ec63e337370ed38afdc + md5: b233b41be0bf210989d57160ed39b394 + depends: + - libgcc >=14 + - __glibc >=2.17,<3.0.a0 + - xorg-libx11 >=1.8.13,<2.0a0 + license: MIT + license_family: MIT + purls: [] + run_exports: {} + size: 441670 + timestamp: 1782027360439 - conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libice-1.1.2-hb9d3cd8_0.conda sha256: c12396aabb21244c212e488bbdc4abcdef0b7404b15761d9329f5a4a39113c4b md5: fb901ff28063514abb6046c9ec2c4a45 @@ -16437,6 +17716,9 @@ packages: license: MIT license_family: MIT purls: [] + run_exports: + weak: + - xorg-libice >=1.1.2,<2.0a0 size: 58628 timestamp: 1734227592886 - conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libsm-1.2.6-he73a12e_0.conda @@ -16450,6 +17732,9 @@ packages: license: MIT license_family: MIT purls: [] + run_exports: + weak: + - xorg-libsm >=1.2.6,<2.0a0 size: 27590 timestamp: 1741896361728 - conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libx11-1.8.13-he1eb515_0.conda @@ -16462,6 +17747,9 @@ packages: license: MIT license_family: MIT purls: [] + run_exports: + weak: + - xorg-libx11 >=1.8.13,<2.0a0 size: 839652 timestamp: 1770819209719 - conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libxau-1.0.12-hb03c661_1.conda @@ -16519,6 +17807,9 @@ packages: license: MIT license_family: MIT purls: [] + run_exports: + weak: + - xorg-libxcursor >=1.2.3,<2.0a0 size: 32533 timestamp: 1730908305254 - conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libxdamage-1.1.6-hb9d3cd8_0.conda @@ -16559,6 +17850,9 @@ packages: license: MIT license_family: MIT purls: [] + run_exports: + weak: + - xorg-libxext >=1.3.7,<2.0a0 size: 50326 timestamp: 1769445253162 - conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libxfixes-6.0.2-hb03c661_0.conda @@ -16571,6 +17865,9 @@ packages: license: MIT license_family: MIT purls: [] + run_exports: + weak: + - xorg-libxfixes >=6.0.2,<7.0a0 size: 20071 timestamp: 1759282564045 - conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libxi-1.8.3-hb03c661_0.conda @@ -16585,6 +17882,9 @@ packages: license: MIT license_family: MIT purls: [] + run_exports: + weak: + - xorg-libxi >=1.8.3,<2.0a0 size: 47717 timestamp: 1779111857071 - conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libxinerama-1.1.6-hecca717_0.conda @@ -16644,6 +17944,9 @@ packages: license: MIT license_family: MIT purls: [] + run_exports: + weak: + - xorg-libxrandr >=1.5.5,<2.0a0 size: 30456 timestamp: 1769445263457 - conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libxrender-0.9.12-hb9d3cd8_0.conda @@ -16656,6 +17959,9 @@ packages: license: MIT license_family: MIT purls: [] + run_exports: + weak: + - xorg-libxrender >=0.9.12,<0.10.0a0 size: 33005 timestamp: 1734229037766 - conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libxscrnsaver-1.2.4-hb9d3cd8_0.conda @@ -16669,6 +17975,9 @@ packages: license: MIT license_family: MIT purls: [] + run_exports: + weak: + - xorg-libxscrnsaver >=1.2.4,<2.0a0 size: 14412 timestamp: 1727899730073 - conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libxshmfence-1.3.3-hb9d3cd8_0.conda @@ -16708,6 +18017,9 @@ packages: license: MIT license_family: MIT purls: [] + run_exports: + weak: + - xorg-libxtst >=1.2.5,<2.0a0 size: 32808 timestamp: 1727964811275 - conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libxxf86vm-1.1.7-hb03c661_0.conda @@ -27730,6 +29042,7 @@ packages: license: BSD-3-Clause license_family: BSD purls: [] + run_exports: {} size: 397370 timestamp: 1566932522327 - conda: https://conda.anaconda.org/conda-forge/noarch/font-ttf-inconsolata-3.000-h77eed37_0.tar.bz2 @@ -27738,6 +29051,7 @@ packages: license: OFL-1.1 license_family: Other purls: [] + run_exports: {} size: 96530 timestamp: 1620479909603 - conda: https://conda.anaconda.org/conda-forge/noarch/font-ttf-source-code-pro-2.038-h77eed37_0.tar.bz2 @@ -27746,6 +29060,7 @@ packages: license: OFL-1.1 license_family: Other purls: [] + run_exports: {} size: 700814 timestamp: 1620479612257 - conda: https://conda.anaconda.org/conda-forge/noarch/font-ttf-ubuntu-0.83-h77eed37_3.conda @@ -27754,6 +29069,7 @@ packages: license: LicenseRef-Ubuntu-Font-Licence-Version-1.0 license_family: Other purls: [] + run_exports: {} size: 1620504 timestamp: 1727511233259 - conda: https://conda.anaconda.org/conda-forge/noarch/fonts-conda-ecosystem-1-0.tar.bz2 @@ -27764,6 +29080,7 @@ packages: license: BSD-3-Clause license_family: BSD purls: [] + run_exports: {} size: 3667 timestamp: 1566974674465 - conda: https://conda.anaconda.org/conda-forge/noarch/fonts-conda-forge-1-hc364b38_1.conda @@ -27777,6 +29094,7 @@ packages: license: BSD-3-Clause license_family: BSD purls: [] + run_exports: {} size: 4059 timestamp: 1762351264405 - conda: https://conda.anaconda.org/conda-forge/noarch/fsspec-2026.6.0-pyhd8ed1ab_0.conda @@ -29033,6 +30351,7 @@ packages: license: MIT license_family: MIT purls: [] + run_exports: {} size: 147954 timestamp: 1780946721169 - conda: https://conda.anaconda.org/conda-forge/noarch/wslink-2.5.7-pyhd8ed1ab_0.conda @@ -49859,6 +51178,16 @@ packages: - opentelemetry-exporter-otlp ; extra == 'open-telemetry' - opentelemetry-sdk ; extra == 'open-telemetry' requires_python: '>=3.9.0' +- pypi: https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl + name: h11 + version: 0.16.0 + sha256: 63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86 + requires_python: '>=3.8' +- pypi: https://files.pythonhosted.org/packages/05/6b/32f747947df2da6994e999492ab306a903659555dddc0fbdeb9d71f75e52/nvidia_cuda_nvrtc_cu12-12.8.93-py3-none-manylinux2010_x86_64.manylinux_2_12_x86_64.whl + name: nvidia-cuda-nvrtc-cu12 + version: 12.8.93 + sha256: a7756528852ef889772a84c6cd89d41dfa74667e24cca16bb31f8f061e3e9994 + requires_python: '>=3' - pypi: https://files.pythonhosted.org/packages/06/79/b4c714bef36bc4ec2beeae1e0c124f0223888cd8c6feb1cdc56038116920/filelock-3.32.0-py3-none-any.whl name: filelock version: 3.32.0 @@ -49875,6 +51204,132 @@ packages: version: 2026.7.22 sha256: 62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775 requires_python: '>=3.7' +- pypi: https://files.pythonhosted.org/packages/0d/9b/a997b638fcd068ad6e4d53b8551a7d30fe8b404d6f1804abf1df69838932/nvidia_cuda_runtime_cu12-12.8.90-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl + name: nvidia-cuda-runtime-cu12 + version: 12.8.90 + sha256: adade8dcbd0edf427b7204d480d6066d33902cab2a4707dcfc48a2d0fd44ab90 + requires_python: '>=3' +- pypi: https://files.pythonhosted.org/packages/12/27/fb8d7338b4d551900fa3e580acbe7a0cf655d940e164cb5c00ec31961094/orderly_set-5.5.0-py3-none-any.whl + name: orderly-set + version: 5.5.0 + sha256: 46f0b801948e98f427b412fcabb831677194c05c3b699b80de260374baa0b1e7 + requires_dist: + - coverage~=7.6.0 ; extra == 'coverage' + - bump2version~=1.0.0 ; extra == 'dev' + - ipdb~=0.13.0 ; extra == 'dev' + - orjson ; extra == 'optimize' + - flake8~=7.1.0 ; extra == 'static' + - flake8-pyproject~=1.2.3 ; extra == 'static' + - pytest~=8.3.0 ; extra == 'test' + - pytest-benchmark~=5.1.0 ; extra == 'test' + - pytest-cov~=6.0.0 ; extra == 'test' + - python-dotenv~=1.0.0 ; extra == 'test' + requires_python: '>=3.8' +- pypi: https://files.pythonhosted.org/packages/12/98/e976540c1addf70442be7842a18cf70884a964abbf69442504f4d2939989/pandas-3.0.5-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl + name: pandas + version: 3.0.5 + sha256: d373ce03ffd84010ed9839fa73672a9c8256990532e158440c0085db7d914b34 + requires_dist: + - numpy>=1.26.0 ; python_full_version < '3.14' + - numpy>=2.3.3 ; python_full_version >= '3.14' + - python-dateutil>=2.8.2 + - tzdata ; sys_platform == 'win32' + - tzdata ; sys_platform == 'emscripten' + - hypothesis>=6.116.0 ; extra == 'test' + - pytest>=8.3.4,<9.1 ; extra == 'test' + - pytest-xdist>=3.6.1 ; extra == 'test' + - pyarrow>=13.0.0 ; extra == 'pyarrow' + - bottleneck>=1.4.2 ; extra == 'performance' + - numba>=0.60.0 ; extra == 'performance' + - numexpr>=2.10.2 ; extra == 'performance' + - scipy>=1.14.1 ; extra == 'computation' + - xarray>=2024.10.0 ; extra == 'computation' + - fsspec>=2024.10.0 ; extra == 'fss' + - s3fs>=2024.10.0 ; extra == 'aws' + - gcsfs>=2024.10.0 ; extra == 'gcp' + - odfpy>=1.4.1 ; extra == 'excel' + - openpyxl>=3.1.5 ; extra == 'excel' + - python-calamine>=0.3.0 ; extra == 'excel' + - pyxlsb>=1.0.10 ; extra == 'excel' + - xlrd>=2.0.1 ; extra == 'excel' + - xlsxwriter>=3.2.0 ; extra == 'excel' + - pyarrow>=13.0.0 ; extra == 'parquet' + - pyarrow>=13.0.0 ; extra == 'feather' + - pyiceberg>=0.8.1 ; extra == 'iceberg' + - tables>=3.10.1 ; extra == 'hdf5' + - pyreadstat>=1.2.8 ; extra == 'spss' + - sqlalchemy>=2.0.36 ; extra == 'postgresql' + - psycopg2>=2.9.10 ; extra == 'postgresql' + - adbc-driver-postgresql>=1.2.0 ; extra == 'postgresql' + - sqlalchemy>=2.0.36 ; extra == 'mysql' + - pymysql>=1.1.1 ; extra == 'mysql' + - sqlalchemy>=2.0.36 ; extra == 'sql-other' + - adbc-driver-postgresql>=1.2.0 ; extra == 'sql-other' + - adbc-driver-sqlite>=1.2.0 ; extra == 'sql-other' + - beautifulsoup4>=4.12.3 ; extra == 'html' + - html5lib>=1.1 ; extra == 'html' + - lxml>=5.3.0 ; extra == 'html' + - lxml>=5.3.0 ; extra == 'xml' + - matplotlib>=3.9.3 ; extra == 'plot' + - jinja2>=3.1.5 ; extra == 'output-formatting' + - tabulate>=0.9.0 ; extra == 'output-formatting' + - pyqt5>=5.15.9 ; extra == 'clipboard' + - qtpy>=2.4.2 ; extra == 'clipboard' + - zstandard>=0.23.0 ; extra == 'compression' + - pytz>=2020.1 ; extra == 'timezone' + - adbc-driver-postgresql>=1.2.0 ; extra == 'all' + - adbc-driver-sqlite>=1.2.0 ; extra == 'all' + - beautifulsoup4>=4.12.3 ; extra == 'all' + - bottleneck>=1.4.2 ; extra == 'all' + - fastparquet>=2024.11.0 ; extra == 'all' + - fsspec>=2024.10.0 ; extra == 'all' + - gcsfs>=2024.10.0 ; extra == 'all' + - html5lib>=1.1 ; extra == 'all' + - hypothesis>=6.116.0 ; extra == 'all' + - jinja2>=3.1.5 ; extra == 'all' + - lxml>=5.3.0 ; extra == 'all' + - matplotlib>=3.9.3 ; extra == 'all' + - numba>=0.60.0 ; extra == 'all' + - numexpr>=2.10.2 ; extra == 'all' + - odfpy>=1.4.1 ; extra == 'all' + - openpyxl>=3.1.5 ; extra == 'all' + - psycopg2>=2.9.10 ; extra == 'all' + - pyarrow>=13.0.0 ; extra == 'all' + - pyiceberg>=0.8.1 ; extra == 'all' + - pymysql>=1.1.1 ; extra == 'all' + - pyqt5>=5.15.9 ; extra == 'all' + - pyreadstat>=1.2.8 ; extra == 'all' + - pytest>=8.3.4 ; extra == 'all' + - pytest-xdist>=3.6.1 ; extra == 'all' + - python-calamine>=0.3.0 ; extra == 'all' + - pytz>=2020.1 ; extra == 'all' + - pyxlsb>=1.0.10 ; extra == 'all' + - qtpy>=2.4.2 ; extra == 'all' + - scipy>=1.14.1 ; extra == 'all' + - s3fs>=2024.10.0 ; extra == 'all' + - sqlalchemy>=2.0.36 ; extra == 'all' + - tables>=3.10.1 ; extra == 'all' + - tabulate>=0.9.0 ; extra == 'all' + - xarray>=2024.10.0 ; extra == 'all' + - xlrd>=2.0.1 ; extra == 'all' + - xlsxwriter>=3.2.0 ; extra == 'all' + - zstandard>=0.23.0 ; extra == 'all' + requires_python: '>=3.11' +- pypi: https://files.pythonhosted.org/packages/14/15/5574111ae50dd6e879456888c0eadd4c5a869959775854e18e18a6b345f3/propcache-0.5.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl + name: propcache + version: 0.5.2 + sha256: 6f328175a2cde1f0ff2c4ed8ce968b9dcfb55f3a7153f39e2957ed994da13476 + requires_python: '>=3.10' +- pypi: https://files.pythonhosted.org/packages/16/92/d1e32e3e0d894fe00b15ce28ad4944ab692713f2e7f0a99787405e43533a/protobuf-6.33.6-cp39-abi3-manylinux2014_x86_64.whl + name: protobuf + version: 6.33.6 + sha256: e9db7e292e0ab79dd108d7f1a94fe31601ce1ee3f7b79e0692043423020b0593 + requires_python: '>=3.9' +- pypi: https://files.pythonhosted.org/packages/18/96/34db781c8f0cf99c544ca1f2bc2e5bf55426e1eb4ca6de8ea5da56a9f352/xxhash-3.8.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl + name: xxhash + version: 3.8.1 + sha256: 82c0cedd280eab2e8291270e6c04894dbc096f8159a39dcf1807429f026ca3cc + requires_python: '>=3.8' - pypi: https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl name: idna version: '3.18' @@ -49884,6 +51339,26 @@ packages: - mypy>=1.11.2 ; extra == 'all' - pytest>=8.3.2 ; extra == 'all' requires_python: '>=3.9' +- pypi: https://files.pythonhosted.org/packages/1e/77/dc8c558f7593132cf8fefec57c4f60c83b16941c574ac5f619abb3ae7933/dill-0.4.1-py3-none-any.whl + name: dill + version: 0.4.1 + sha256: 1e1ce33e978ae97fcfcff5638477032b801c46c7c65cf717f95fbc2248f79a9d + requires_dist: + - objgraph>=1.7.2 ; extra == 'graph' + - gprof2dot>=2022.7.29 ; extra == 'profile' + requires_python: '>=3.9' +- pypi: https://files.pythonhosted.org/packages/1f/13/ee4e00f30e676b66ae65b4f08cb5bcbb8392c03f54f2d5413ea99a5d1c80/nvidia_cufft_cu12-11.3.3.83-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl + name: nvidia-cufft-cu12 + version: 11.3.3.83 + sha256: 4d2dd21ec0b88cf61b62e6b43564355e5222e4a3fb394cac0db101f2dd0d4f74 + requires_dist: + - nvidia-nvjitlink-cu12 + requires_python: '>=3' +- pypi: https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl + name: packaging + version: '25.0' + sha256: 29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484 + requires_python: '>=3.8' - pypi: https://files.pythonhosted.org/packages/28/50/f203ff3a3ddfe19308efc83c5a3a29ed02bf786732ec35e68bf9162f3365/safetensors-0.8.0-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl name: safetensors version: 0.8.0 @@ -49926,11 +51401,174 @@ packages: - safetensors[numpy] ; extra == 'torch' - torch>=2.4 ; extra == 'torch' requires_python: '>=3.10' +- pypi: https://files.pythonhosted.org/packages/29/34/ccc711b6dc581e43b8d8d227e4173a8826994ee7b68d6b3d82291f307325/torchcodec-0.10.0-cp312-cp312-manylinux_2_28_x86_64.whl + name: torchcodec + version: 0.10.0 + sha256: 6e43184d83ccced965b31cad5bb6200c779646fee2ec153a6d784b4def40c91b + requires_dist: + - numpy ; extra == 'dev' + - pytest ; extra == 'dev' + - pillow ; extra == 'dev' + requires_python: '>=3.10' +- pypi: https://files.pythonhosted.org/packages/2a/09/f8d8f8f31e4483c10a906437b4ce31bdf3d6d417b73fe33f1a8b59e34228/einops-0.8.2-py3-none-any.whl + name: einops + version: 0.8.2 + sha256: 54058201ac7087911181bfec4af6091bb59380360f069276601256a76af08193 + requires_python: '>=3.9' +- pypi: https://files.pythonhosted.org/packages/2a/2e/38d9824f8c6bb048c5ba21c6d4da54c29c162a46b58b3ef907a360a76d3e/diffusers-0.35.2-py3-none-any.whl + name: diffusers + version: 0.35.2 + sha256: d50d5e74fdd6dcf55e5c1d304bc52cc7c2659abd1752740d736d7b54078b4db5 + requires_dist: + - importlib-metadata + - filelock + - huggingface-hub>=0.34.0 + - numpy + - regex!=2019.12.17 + - requests + - safetensors>=0.3.1 + - pillow + - urllib3<=2.0.0 ; extra == 'quality' + - isort>=5.5.4 ; extra == 'quality' + - ruff==0.9.10 ; extra == 'quality' + - hf-doc-builder>=0.3.0 ; extra == 'quality' + - hf-doc-builder>=0.3.0 ; extra == 'docs' + - accelerate>=0.31.0 ; extra == 'training' + - datasets ; extra == 'training' + - protobuf>=3.20.3,<4 ; extra == 'training' + - tensorboard ; extra == 'training' + - jinja2 ; extra == 'training' + - peft>=0.17.0 ; extra == 'training' + - compel==0.1.8 ; extra == 'test' + - gitpython<3.1.19 ; extra == 'test' + - datasets ; extra == 'test' + - jinja2 ; extra == 'test' + - invisible-watermark>=0.2.0 ; extra == 'test' + - k-diffusion==0.0.12 ; extra == 'test' + - librosa ; extra == 'test' + - parameterized ; extra == 'test' + - pytest ; extra == 'test' + - pytest-timeout ; extra == 'test' + - pytest-xdist ; extra == 'test' + - requests-mock==1.10.0 ; extra == 'test' + - safetensors>=0.3.1 ; extra == 'test' + - sentencepiece>=0.1.91,!=0.1.92 ; extra == 'test' + - scipy ; extra == 'test' + - tiktoken>=0.7.0 ; extra == 'test' + - torchvision ; extra == 'test' + - transformers>=4.41.2 ; extra == 'test' + - phonemizer ; extra == 'test' + - torch>=1.4 ; extra == 'torch' + - accelerate>=0.31.0 ; extra == 'torch' + - bitsandbytes>=0.43.3 ; extra == 'bitsandbytes' + - accelerate>=0.31.0 ; extra == 'bitsandbytes' + - gguf>=0.10.0 ; extra == 'gguf' + - accelerate>=0.31.0 ; extra == 'gguf' + - optimum-quanto>=0.2.6 ; extra == 'optimum-quanto' + - accelerate>=0.31.0 ; extra == 'optimum-quanto' + - torchao>=0.7.0 ; extra == 'torchao' + - accelerate>=0.31.0 ; extra == 'torchao' + - jax>=0.4.1 ; extra == 'flax' + - jaxlib>=0.4.1 ; extra == 'flax' + - flax>=0.4.1 ; extra == 'flax' + - urllib3<=2.0.0 ; extra == 'dev' + - isort>=5.5.4 ; extra == 'dev' + - ruff==0.9.10 ; extra == 'dev' + - hf-doc-builder>=0.3.0 ; extra == 'dev' + - compel==0.1.8 ; extra == 'dev' + - gitpython<3.1.19 ; extra == 'dev' + - datasets ; extra == 'dev' + - jinja2 ; extra == 'dev' + - invisible-watermark>=0.2.0 ; extra == 'dev' + - k-diffusion==0.0.12 ; extra == 'dev' + - librosa ; extra == 'dev' + - parameterized ; extra == 'dev' + - pytest ; extra == 'dev' + - pytest-timeout ; extra == 'dev' + - pytest-xdist ; extra == 'dev' + - requests-mock==1.10.0 ; extra == 'dev' + - safetensors>=0.3.1 ; extra == 'dev' + - sentencepiece>=0.1.91,!=0.1.92 ; extra == 'dev' + - scipy ; extra == 'dev' + - tiktoken>=0.7.0 ; extra == 'dev' + - torchvision ; extra == 'dev' + - transformers>=4.41.2 ; extra == 'dev' + - phonemizer ; extra == 'dev' + - accelerate>=0.31.0 ; extra == 'dev' + - datasets ; extra == 'dev' + - protobuf>=3.20.3,<4 ; extra == 'dev' + - tensorboard ; extra == 'dev' + - jinja2 ; extra == 'dev' + - peft>=0.17.0 ; extra == 'dev' + - hf-doc-builder>=0.3.0 ; extra == 'dev' + - torch>=1.4 ; extra == 'dev' + - accelerate>=0.31.0 ; extra == 'dev' + - jax>=0.4.1 ; extra == 'dev' + - jaxlib>=0.4.1 ; extra == 'dev' + - flax>=0.4.1 ; extra == 'dev' + requires_python: '>=3.8.0' +- pypi: https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl + name: httpx + version: 0.28.1 + sha256: d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad + requires_dist: + - anyio + - certifi + - httpcore==1.* + - idna + - brotli ; platform_python_implementation == 'CPython' and extra == 'brotli' + - brotlicffi ; platform_python_implementation != 'CPython' and extra == 'brotli' + - click==8.* ; extra == 'cli' + - pygments==2.* ; extra == 'cli' + - rich>=10,<14 ; extra == 'cli' + - h2>=3,<5 ; extra == 'http2' + - socksio==1.* ; extra == 'socks' + - zstandard>=0.18.0 ; extra == 'zstd' + requires_python: '>=3.8' - pypi: https://files.pythonhosted.org/packages/2a/8e/096d00c7c480ef2ff4265349b14e2261d4ab787ba1f74e2e80d1c58079c3/regex-2026.7.19-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl name: regex version: 2026.7.19 sha256: 9dce8ec9695f531a1b8a6f314fd4b393adcccf2ea861db480cdf97a301d01a68 requires_python: '>=3.10' +- pypi: https://files.pythonhosted.org/packages/2b/5f/c52bd1255db763d0cdcb7084d2e90c42119cb229302c56bdf1d0aa78abd2/deepdiff-8.6.2-py3-none-any.whl + name: deepdiff + version: 8.6.2 + sha256: 4d22034a866c3928303a9332c279362f714192d9305bac17c498720d095fd1b4 + requires_dist: + - orderly-set>=5.4.1,<6 + - click~=8.1.0 ; extra == 'cli' + - pyyaml~=6.0.0 ; extra == 'cli' + - coverage~=7.6.0 ; extra == 'coverage' + - bump2version~=1.0.0 ; extra == 'dev' + - jsonpickle~=4.0.0 ; extra == 'dev' + - ipdb~=0.13.0 ; extra == 'dev' + - numpy~=2.2.0 ; python_full_version >= '3.10' and extra == 'dev' + - numpy~=2.0 ; python_full_version < '3.10' and extra == 'dev' + - python-dateutil~=2.9.0 ; extra == 'dev' + - orjson~=3.10.0 ; extra == 'dev' + - tomli~=2.2.0 ; extra == 'dev' + - tomli-w~=1.2.0 ; extra == 'dev' + - pandas~=2.2.0 ; extra == 'dev' + - polars~=1.21.0 ; extra == 'dev' + - nox==2025.5.1 ; extra == 'dev' + - uuid6==2025.0.1 ; extra == 'dev' + - sphinx~=6.2.0 ; extra == 'docs' + - sphinx-sitemap~=2.6.0 ; extra == 'docs' + - sphinxemoji~=0.3.0 ; extra == 'docs' + - orjson ; extra == 'optimize' + - flake8~=7.1.0 ; extra == 'static' + - flake8-pyproject~=1.2.3 ; extra == 'static' + - pydantic~=2.10.0 ; extra == 'static' + - pytest~=8.3.0 ; extra == 'test' + - pytest-benchmark~=5.1.0 ; extra == 'test' + - pytest-cov~=6.0.0 ; extra == 'test' + - python-dotenv~=1.0.0 ; extra == 'test' + requires_python: '>=3.9' +- pypi: https://files.pythonhosted.org/packages/2c/19/04f9b178c2d8a15b076c8b5140708fa6ffc5601fb6f1e975537072df5b2a/mergedeep-1.3.4-py3-none-any.whl + name: mergedeep + version: 1.3.4 + sha256: 70775750742b25c0d8f36c55aed03d24c3384d17c951b3175d898bd778ef0307 + requires_python: '>=3.6' - pypi: https://files.pythonhosted.org/packages/2e/76/932be4b50ef6ccedf9d3c6639b056a967a86258c6d9200643f01269211ca/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl name: tokenizers version: 0.22.2 @@ -49949,11 +51587,261 @@ packages: - setuptools-rust ; extra == 'docs' - tokenizers[testing] ; extra == 'dev' requires_python: '>=3.9' +- pypi: https://files.pythonhosted.org/packages/31/a0/651f93d154cb72323358bf2bbae3e642bdb5d2f1bfc874d096f7cb159fa0/huggingface_hub-0.35.3-py3-none-any.whl + name: huggingface-hub + version: 0.35.3 + sha256: 0e3a01829c19d86d03793e4577816fe3bdfc1602ac62c7fb220d593d351224ba + requires_dist: + - filelock + - fsspec>=2023.5.0 + - packaging>=20.9 + - pyyaml>=5.1 + - requests + - tqdm>=4.42.1 + - typing-extensions>=3.7.4.3 + - hf-xet>=1.1.3,<2.0.0 ; platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'arm64' or platform_machine == 'x86_64' + - inquirerpy==0.3.4 ; extra == 'all' + - aiohttp ; extra == 'all' + - authlib>=1.3.2 ; extra == 'all' + - fastapi ; extra == 'all' + - httpx ; extra == 'all' + - itsdangerous ; extra == 'all' + - jedi ; extra == 'all' + - jinja2 ; extra == 'all' + - pytest>=8.1.1,<8.2.2 ; extra == 'all' + - pytest-cov ; extra == 'all' + - pytest-env ; extra == 'all' + - pytest-xdist ; extra == 'all' + - pytest-vcr ; extra == 'all' + - pytest-asyncio ; extra == 'all' + - pytest-rerunfailures<16.0 ; extra == 'all' + - pytest-mock ; extra == 'all' + - urllib3<2.0 ; extra == 'all' + - soundfile ; extra == 'all' + - pillow ; extra == 'all' + - gradio>=4.0.0 ; extra == 'all' + - numpy ; extra == 'all' + - ruff>=0.9.0 ; extra == 'all' + - libcst>=1.4.0 ; extra == 'all' + - ty ; extra == 'all' + - typing-extensions>=4.8.0 ; extra == 'all' + - types-pyyaml ; extra == 'all' + - types-requests ; extra == 'all' + - types-simplejson ; extra == 'all' + - types-toml ; extra == 'all' + - types-tqdm ; extra == 'all' + - types-urllib3 ; extra == 'all' + - mypy>=1.14.1,<1.15.0 ; python_full_version == '3.8.*' and extra == 'all' + - mypy==1.15.0 ; python_full_version >= '3.9' and extra == 'all' + - inquirerpy==0.3.4 ; extra == 'cli' + - inquirerpy==0.3.4 ; extra == 'dev' + - aiohttp ; extra == 'dev' + - authlib>=1.3.2 ; extra == 'dev' + - fastapi ; extra == 'dev' + - httpx ; extra == 'dev' + - itsdangerous ; extra == 'dev' + - jedi ; extra == 'dev' + - jinja2 ; extra == 'dev' + - pytest>=8.1.1,<8.2.2 ; extra == 'dev' + - pytest-cov ; extra == 'dev' + - pytest-env ; extra == 'dev' + - pytest-xdist ; extra == 'dev' + - pytest-vcr ; extra == 'dev' + - pytest-asyncio ; extra == 'dev' + - pytest-rerunfailures<16.0 ; extra == 'dev' + - pytest-mock ; extra == 'dev' + - urllib3<2.0 ; extra == 'dev' + - soundfile ; extra == 'dev' + - pillow ; extra == 'dev' + - gradio>=4.0.0 ; extra == 'dev' + - numpy ; extra == 'dev' + - ruff>=0.9.0 ; extra == 'dev' + - libcst>=1.4.0 ; extra == 'dev' + - ty ; extra == 'dev' + - typing-extensions>=4.8.0 ; extra == 'dev' + - types-pyyaml ; extra == 'dev' + - types-requests ; extra == 'dev' + - types-simplejson ; extra == 'dev' + - types-toml ; extra == 'dev' + - types-tqdm ; extra == 'dev' + - types-urllib3 ; extra == 'dev' + - mypy>=1.14.1,<1.15.0 ; python_full_version == '3.8.*' and extra == 'dev' + - mypy==1.15.0 ; python_full_version >= '3.9' and extra == 'dev' + - toml ; extra == 'fastai' + - fastai>=2.4 ; extra == 'fastai' + - fastcore>=1.3.27 ; extra == 'fastai' + - hf-transfer>=0.1.4 ; extra == 'hf-transfer' + - hf-xet>=1.1.2,<2.0.0 ; extra == 'hf-xet' + - aiohttp ; extra == 'inference' + - mcp>=1.8.0 ; extra == 'mcp' + - typer ; extra == 'mcp' + - aiohttp ; extra == 'mcp' + - authlib>=1.3.2 ; extra == 'oauth' + - fastapi ; extra == 'oauth' + - httpx ; extra == 'oauth' + - itsdangerous ; extra == 'oauth' + - ruff>=0.9.0 ; extra == 'quality' + - libcst>=1.4.0 ; extra == 'quality' + - ty ; extra == 'quality' + - mypy>=1.14.1,<1.15.0 ; python_full_version == '3.8.*' and extra == 'quality' + - mypy==1.15.0 ; python_full_version >= '3.9' and extra == 'quality' + - tensorflow ; extra == 'tensorflow' + - pydot ; extra == 'tensorflow' + - graphviz ; extra == 'tensorflow' + - tensorflow ; extra == 'tensorflow-testing' + - keras<3.0 ; extra == 'tensorflow-testing' + - inquirerpy==0.3.4 ; extra == 'testing' + - aiohttp ; extra == 'testing' + - authlib>=1.3.2 ; extra == 'testing' + - fastapi ; extra == 'testing' + - httpx ; extra == 'testing' + - itsdangerous ; extra == 'testing' + - jedi ; extra == 'testing' + - jinja2 ; extra == 'testing' + - pytest>=8.1.1,<8.2.2 ; extra == 'testing' + - pytest-cov ; extra == 'testing' + - pytest-env ; extra == 'testing' + - pytest-xdist ; extra == 'testing' + - pytest-vcr ; extra == 'testing' + - pytest-asyncio ; extra == 'testing' + - pytest-rerunfailures<16.0 ; extra == 'testing' + - pytest-mock ; extra == 'testing' + - urllib3<2.0 ; extra == 'testing' + - soundfile ; extra == 'testing' + - pillow ; extra == 'testing' + - gradio>=4.0.0 ; extra == 'testing' + - numpy ; extra == 'testing' + - torch ; extra == 'torch' + - safetensors[torch] ; extra == 'torch' + - typing-extensions>=4.8.0 ; extra == 'typing' + - types-pyyaml ; extra == 'typing' + - types-requests ; extra == 'typing' + - types-simplejson ; extra == 'typing' + - types-toml ; extra == 'typing' + - types-tqdm ; extra == 'typing' + - types-urllib3 ; extra == 'typing' + requires_python: '>=3.8.0' +- pypi: https://files.pythonhosted.org/packages/33/d1/8bb87d21e9aeb323cc03034f5eaf2c8f69841e40e4853c2627edf8111ed3/termcolor-3.3.0-py3-none-any.whl + name: termcolor + version: 3.3.0 + sha256: cf642efadaf0a8ebbbf4bc7a31cec2f9b5f21a9f726f4ccbb08192c9c26f43a5 + requires_dist: + - pytest ; extra == 'tests' + - pytest-cov ; extra == 'tests' + requires_python: '>=3.10' +- pypi: https://files.pythonhosted.org/packages/38/3d/2d244233ac4f76e38533cfcb2991c9eb4c7bf688ae0a036d30725b8faafe/importlib_metadata-9.0.0-py3-none-any.whl + name: importlib-metadata + version: 9.0.0 + sha256: 2d21d1cc5a017bd0559e36150c21c830ab1dc304dedd1b7ea85d20f45ef3edd7 + requires_dist: + - zipp>=3.20 + - pytest>=6,!=8.1.* ; extra == 'test' + - packaging ; extra == 'test' + - pyfakefs ; extra == 'test' + - pytest-perf>=0.9.2 ; extra == 'test' + - sphinx>=3.5 ; extra == 'doc' + - jaraco-packaging>=9.3 ; extra == 'doc' + - rst-linker>=1.9 ; extra == 'doc' + - furo ; extra == 'doc' + - sphinx-lint ; extra == 'doc' + - jaraco-tidelift>=1.4 ; extra == 'doc' + - ipython ; extra == 'perf' + - pytest-checkdocs>=2.14 ; extra == 'check' + - pytest-ruff>=0.2.1 ; sys_platform != 'cygwin' and extra == 'check' + - pytest-cov ; extra == 'cover' + - pytest-enabler>=3.4 ; extra == 'enabler' + - pytest-mypy>=1.0.1 ; platform_python_implementation != 'PyPy' and extra == 'type' + requires_python: '>=3.10' +- pypi: https://files.pythonhosted.org/packages/3a/13/547360d81e6d88d58492968ffda9f9542854f11310ee556fef14260cc886/zipp-4.1.0-py3-none-any.whl + name: zipp + version: 4.1.0 + sha256: 25ad4e16390cd314347dd8f1de67a2ac538ae658ed4ab9db16029c07c188e97f + requires_dist: + - pytest>=6,!=8.1.* ; extra == 'test' + - jaraco-itertools ; extra == 'test' + - jaraco-functools ; extra == 'test' + - more-itertools ; extra == 'test' + - big-o ; extra == 'test' + - pytest-ignore-flaky ; extra == 'test' + - jaraco-test ; extra == 'test' + - sphinx>=3.5 ; extra == 'doc' + - jaraco-packaging>=9.3 ; extra == 'doc' + - rst-linker>=1.9 ; extra == 'doc' + - furo ; extra == 'doc' + - sphinx-lint ; extra == 'doc' + - jaraco-tidelift>=1.4 ; extra == 'doc' + - pytest-checkdocs>=2.14 ; extra == 'check' + - pytest-ruff>=0.2.1 ; sys_platform != 'cygwin' and extra == 'check' + - pytest-cov ; extra == 'cover' + - pytest-enabler>=3.4 ; extra == 'enabler' + - pytest-mypy>=1.0.1 ; platform_python_implementation != 'PyPy' and extra == 'type' + requires_python: '>=3.10' - pypi: https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl name: markupsafe version: 3.0.3 sha256: d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d requires_python: '>=3.9' +- pypi: https://files.pythonhosted.org/packages/3e/2d/ca050652104bab2cf55e569db2a178b1b61cb041fef28307f2db383f6d9f/imageio-2.37.4-py3-none-any.whl + name: imageio + version: 2.37.4 + sha256: 1ab2e22c8debf700f24c3ac43e8f95f3b3a8110c83b93411e97b4b0b2cd1c7e6 + requires_dist: + - numpy + - pillow>=8.3.2 + - imageio-ffmpeg ; extra == 'ffmpeg' + - psutil ; extra == 'ffmpeg' + - fsspec[http] ; extra == 'freeimage' + - pillow-heif ; extra == 'pillow-heif' + - tifffile ; extra == 'tifffile' + - av ; extra == 'pyav' + - astropy ; extra == 'fits' + - rawpy ; extra == 'rawpy' + - numpy>2 ; extra == 'rawpy' + - gdal ; extra == 'gdal' + - itk ; extra == 'itk' + - black ; extra == 'linting' + - flake8 ; extra == 'linting' + - pytest ; extra == 'test' + - pytest-cov ; extra == 'test' + - fsspec[github] ; extra == 'test' + - sphinx<6 ; extra == 'docs' + - numpydoc ; extra == 'docs' + - pydata-sphinx-theme ; extra == 'docs' + - pytest ; extra == 'dev' + - pytest-cov ; extra == 'dev' + - fsspec[github] ; extra == 'dev' + - black ; extra == 'dev' + - flake8 ; extra == 'dev' + - av ; extra == 'all-plugins' + - astropy ; extra == 'all-plugins' + - fsspec[http] ; extra == 'all-plugins' + - imageio-ffmpeg ; extra == 'all-plugins' + - numpy>2 ; extra == 'all-plugins' + - pillow-heif ; extra == 'all-plugins' + - psutil ; extra == 'all-plugins' + - rawpy ; extra == 'all-plugins' + - tifffile ; extra == 'all-plugins' + - fsspec[http] ; extra == 'all-plugins-pypy' + - imageio-ffmpeg ; extra == 'all-plugins-pypy' + - pillow-heif ; extra == 'all-plugins-pypy' + - psutil ; extra == 'all-plugins-pypy' + - astropy ; extra == 'full' + - av ; extra == 'full' + - black ; extra == 'full' + - flake8 ; extra == 'full' + - fsspec[github,http] ; extra == 'full' + - imageio-ffmpeg ; extra == 'full' + - numpydoc ; extra == 'full' + - numpy>2 ; extra == 'full' + - pillow-heif ; extra == 'full' + - psutil ; extra == 'full' + - pydata-sphinx-theme ; extra == 'full' + - pytest ; extra == 'full' + - pytest-cov ; extra == 'full' + - rawpy ; extra == 'full' + - sphinx<6 ; extra == 'full' + - tifffile ; extra == 'full' + requires_python: '>=3.10' - pypi: https://files.pythonhosted.org/packages/43/e3/7d92a15f894aa0c9c4b49b8ee9ac9850d6e63b03c9c32c0367a13ae62209/mpmath-1.3.0-py3-none-any.whl name: mpmath version: 1.3.0 @@ -49967,11 +51855,51 @@ packages: - sphinx ; extra == 'docs' - gmpy2>=2.1.0a4 ; platform_python_implementation != 'PyPy' and extra == 'gmpy' - pytest>=4.6 ; extra == 'tests' +- pypi: https://files.pythonhosted.org/packages/44/6f/7120676b6d73228c96e17f1f794d8ab046fc910d781c8d151120c3f1569e/toml-0.10.2-py2.py3-none-any.whl + name: toml + version: 0.10.2 + sha256: 806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b + requires_python: '>=2.6,!=3.0.*,!=3.1.*,!=3.2.*' - pypi: https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl name: typing-extensions version: 4.16.0 sha256: 481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8 requires_python: '>=3.9' +- pypi: https://files.pythonhosted.org/packages/52/b7/7cd31f29d6055bd711ae6e669367fba6f5ae9de463910a793e30556a8db7/aiohttp-3.14.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl + name: aiohttp + version: 3.14.3 + sha256: 543906c127fb1d929b95076db19b83fa2d46751006ff1e23b093aa5ac4d8db42 + requires_dist: + - aiohappyeyeballs>=2.5.0 + - aiosignal>=1.4.0 + - async-timeout>=4.0,<6.0 ; python_full_version < '3.11' + - attrs>=17.3.0 + - frozenlist>=1.1.1 + - multidict>=4.5,<7.0 + - propcache>=0.2.0 + - typing-extensions>=4.4 ; python_full_version < '3.13' + - yarl>=1.17.0,<2.0 + - aiodns>=3.3.0 ; sys_platform != 'android' and sys_platform != 'ios' and extra == 'speedups' + - brotli>=1.2 ; platform_python_implementation == 'CPython' and sys_platform != 'android' and sys_platform != 'ios' and extra == 'speedups' + - brotlicffi>=1.2 ; platform_python_implementation != 'CPython' and extra == 'speedups' + - backports-zstd ; python_full_version < '3.14' and platform_python_implementation == 'CPython' and sys_platform != 'android' and sys_platform != 'ios' and extra == 'speedups' + requires_python: '>=3.10' +- pypi: https://files.pythonhosted.org/packages/54/6f/84908cad2d6aa5144abcf7b42709fe4fdb459bc640ec7ac5786e7693dabc/prompt_toolkit-3.0.53-py3-none-any.whl + name: prompt-toolkit + version: 3.0.53 + sha256: 01c0891d7f9237d5e339f7d3e42cdae80b7534abb1c7c0e3352efba6231492f2 + requires_dist: + - wcwidth>=0.1.4 + requires_python: '>=3.10' +- pypi: https://files.pythonhosted.org/packages/55/0e/cd705c042bc4fe7022478db577fcab4abdcfabb9bc37ab7a75556b3fcb2b/pyarrow-25.0.0-cp312-cp312-manylinux_2_28_x86_64.whl + name: pyarrow + version: 25.0.0 + sha256: 5d1dbf24e151042f2fa3c129563f65d66674128868496fb008c4272b16bdf778 + requires_python: '>=3.10' +- pypi: https://files.pythonhosted.org/packages/56/79/12978b96bd44274fe38b5dde5cfb660b1d114f70a65ef962bcbbed99b549/nvidia_cusparselt_cu12-0.7.1-py3-none-manylinux2014_x86_64.whl + name: nvidia-cusparselt-cu12 + version: 0.7.1 + sha256: f1bb701d6b930d5a7cea44c19ceb973311500847f81b634d802b7b539dc55623 - pypi: https://files.pythonhosted.org/packages/5d/40/e1e72872c6354b306daef1703549e8e83b4d43cfea356311bf722a043752/setuptools-83.0.0-py3-none-any.whl name: setuptools version: 83.0.0 @@ -50034,6 +51962,13 @@ packages: sha256: d4d3832e4b1b22a8222133a414db9f868224c2fb639426a1b11d96ddfe84e69c requires_dist: - pyserial +- pypi: https://files.pythonhosted.org/packages/5f/97/2aab507d3d00ca626e8e57c1eac6a79e4e5fbcc63eb99733ff55d1717f65/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl + name: pydantic-core + version: 2.46.4 + sha256: 926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce + requires_dist: + - typing-extensions>=4.14.1 + requires_python: '>=3.9' - pypi: https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl name: jinja2 version: 3.1.6 @@ -50042,6 +51977,287 @@ packages: - markupsafe>=2.0 - babel>=2.7 ; extra == 'i18n' requires_python: '>=3.7' +- pypi: https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl + name: attrs + version: 26.1.0 + sha256: c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309 + requires_python: '>=3.9' +- pypi: https://files.pythonhosted.org/packages/65/99/00f3196036501b53032c4b1ab8337a0b978dee832ed276dae3815df4e8b5/datasets-4.8.5-py3-none-any.whl + name: datasets + version: 4.8.5 + sha256: 5079900781719c0e063a8efdd2cd95a31ad0c63209178669cd23cf1b926149ff + requires_dist: + - filelock + - numpy>=1.17 + - pyarrow>=21.0.0 + - dill>=0.3.0,<0.4.2 + - pandas + - requests>=2.32.2 + - httpx<1.0.0 + - tqdm>=4.66.3 + - xxhash + - multiprocess<0.70.20 + - fsspec[http]>=2023.1.0,<=2026.2.0 + - huggingface-hub>=0.25.0,<2.0 + - packaging + - pyyaml>=5.1 + - torchcodec>=0.6.0 ; extra == 'audio' + - torch>=2.8.0 ; extra == 'audio' + - pillow>=9.4.0 ; extra == 'vision' + - tensorflow>=2.6.0 ; extra == 'tensorflow' + - tensorflow>=2.6.0 ; extra == 'tensorflow-gpu' + - torch ; extra == 'torch' + - jax>=0.3.14 ; extra == 'jax' + - jaxlib>=0.3.14 ; extra == 'jax' + - numba>=0.56.4 ; python_full_version < '3.14' and extra == 'dev' + - absl-py ; extra == 'dev' + - decorator ; extra == 'dev' + - joblib<1.3.0 ; extra == 'dev' + - joblibspark ; python_full_version < '3.14' and extra == 'dev' + - pytest ; extra == 'dev' + - pytest-datadir ; extra == 'dev' + - pytest-xdist ; extra == 'dev' + - aiohttp ; extra == 'dev' + - elasticsearch>=7.17.12,<8.0.0 ; extra == 'dev' + - faiss-cpu>=1.8.0.post1 ; extra == 'dev' + - h5py ; extra == 'dev' + - pylance ; extra == 'dev' + - jax>=0.3.14 ; sys_platform != 'win32' and extra == 'dev' + - jaxlib>=0.3.14 ; sys_platform != 'win32' and extra == 'dev' + - lz4 ; python_full_version < '3.14' and extra == 'dev' + - moto[server] ; extra == 'dev' + - pyspark>=3.4 ; extra == 'dev' + - py7zr ; extra == 'dev' + - rarfile>=4.0 ; extra == 'dev' + - sqlalchemy ; extra == 'dev' + - protobuf<4.0.0 ; extra == 'dev' + - tensorflow>=2.6.0 ; python_full_version < '3.10' and sys_platform != 'win32' and extra == 'dev' + - tensorflow>=2.16.0 ; python_full_version >= '3.10' and python_full_version < '3.14' and sys_platform != 'win32' and extra == 'dev' + - tiktoken ; extra == 'dev' + - torch>=2.8.0 ; extra == 'dev' + - torchdata ; extra == 'dev' + - transformers>=4.42.0 ; extra == 'dev' + - zstandard ; extra == 'dev' + - polars[timezone]>=0.20.0 ; extra == 'dev' + - pillow>=9.4.0 ; extra == 'dev' + - torchcodec>=0.7.0 ; python_full_version < '3.14' and extra == 'dev' + - nibabel>=5.3.1 ; extra == 'dev' + - ruff>=0.3.0 ; extra == 'dev' + - transformers ; extra == 'dev' + - torch ; extra == 'dev' + - tensorflow>=2.6.0 ; extra == 'dev' + - numba>=0.56.4 ; python_full_version < '3.14' and extra == 'tests' + - absl-py ; extra == 'tests' + - decorator ; extra == 'tests' + - joblib<1.3.0 ; extra == 'tests' + - joblibspark ; python_full_version < '3.14' and extra == 'tests' + - pytest ; extra == 'tests' + - pytest-datadir ; extra == 'tests' + - pytest-xdist ; extra == 'tests' + - aiohttp ; extra == 'tests' + - elasticsearch>=7.17.12,<8.0.0 ; extra == 'tests' + - faiss-cpu>=1.8.0.post1 ; extra == 'tests' + - h5py ; extra == 'tests' + - pylance ; extra == 'tests' + - jax>=0.3.14 ; sys_platform != 'win32' and extra == 'tests' + - jaxlib>=0.3.14 ; sys_platform != 'win32' and extra == 'tests' + - lz4 ; python_full_version < '3.14' and extra == 'tests' + - moto[server] ; extra == 'tests' + - pyspark>=3.4 ; extra == 'tests' + - py7zr ; extra == 'tests' + - rarfile>=4.0 ; extra == 'tests' + - sqlalchemy ; extra == 'tests' + - protobuf<4.0.0 ; extra == 'tests' + - tensorflow>=2.6.0 ; python_full_version < '3.10' and sys_platform != 'win32' and extra == 'tests' + - tensorflow>=2.16.0 ; python_full_version >= '3.10' and python_full_version < '3.14' and sys_platform != 'win32' and extra == 'tests' + - tiktoken ; extra == 'tests' + - torch>=2.8.0 ; extra == 'tests' + - torchdata ; extra == 'tests' + - transformers>=4.42.0 ; extra == 'tests' + - zstandard ; extra == 'tests' + - polars[timezone]>=0.20.0 ; extra == 'tests' + - pillow>=9.4.0 ; extra == 'tests' + - torchcodec>=0.7.0 ; python_full_version < '3.14' and extra == 'tests' + - nibabel>=5.3.1 ; extra == 'tests' + - numba>=0.56.4 ; python_full_version < '3.14' and extra == 'tests-numpy2' + - absl-py ; extra == 'tests-numpy2' + - decorator ; extra == 'tests-numpy2' + - joblib<1.3.0 ; extra == 'tests-numpy2' + - joblibspark ; python_full_version < '3.14' and extra == 'tests-numpy2' + - pytest ; extra == 'tests-numpy2' + - pytest-datadir ; extra == 'tests-numpy2' + - pytest-xdist ; extra == 'tests-numpy2' + - aiohttp ; extra == 'tests-numpy2' + - elasticsearch>=7.17.12,<8.0.0 ; extra == 'tests-numpy2' + - h5py ; extra == 'tests-numpy2' + - pylance ; extra == 'tests-numpy2' + - jax>=0.3.14 ; sys_platform != 'win32' and extra == 'tests-numpy2' + - jaxlib>=0.3.14 ; sys_platform != 'win32' and extra == 'tests-numpy2' + - lz4 ; python_full_version < '3.14' and extra == 'tests-numpy2' + - moto[server] ; extra == 'tests-numpy2' + - pyspark>=3.4 ; extra == 'tests-numpy2' + - py7zr ; extra == 'tests-numpy2' + - rarfile>=4.0 ; extra == 'tests-numpy2' + - sqlalchemy ; extra == 'tests-numpy2' + - protobuf<4.0.0 ; extra == 'tests-numpy2' + - tiktoken ; extra == 'tests-numpy2' + - torch>=2.8.0 ; extra == 'tests-numpy2' + - torchdata ; extra == 'tests-numpy2' + - transformers>=4.42.0 ; extra == 'tests-numpy2' + - zstandard ; extra == 'tests-numpy2' + - polars[timezone]>=0.20.0 ; extra == 'tests-numpy2' + - pillow>=9.4.0 ; extra == 'tests-numpy2' + - torchcodec>=0.7.0 ; python_full_version < '3.14' and extra == 'tests-numpy2' + - nibabel>=5.3.1 ; extra == 'tests-numpy2' + - ruff>=0.3.0 ; extra == 'quality' + - tensorflow==2.12.0 ; extra == 'benchmarks' + - torch==2.0.1 ; extra == 'benchmarks' + - transformers==4.30.1 ; extra == 'benchmarks' + - transformers ; extra == 'docs' + - torch ; extra == 'docs' + - tensorflow>=2.6.0 ; extra == 'docs' + - pdfplumber>=0.11.4 ; extra == 'pdfs' + - nibabel>=5.3.2 ; extra == 'nibabel' + - ipyniivue==2.4.2 ; extra == 'nibabel' + requires_python: '>=3.10.0' +- pypi: https://files.pythonhosted.org/packages/65/f3/107a22063bf27bdccf2024833d3445f4eea42b2e598abfbd46f6a63b6cb0/typing_inspect-0.9.0-py3-none-any.whl + name: typing-inspect + version: 0.9.0 + sha256: 9ee6fc59062311ef8547596ab6b955e1b8aa46242d854bfc78f4f6b0eff35f9f + requires_dist: + - mypy-extensions>=0.3.0 + - typing-extensions>=3.7.4 + - typing>=3.7.4 ; python_full_version < '3.5' +- pypi: https://files.pythonhosted.org/packages/67/4e/a28359bf1c1ecf11eba22123168c138698f7cb576ac678f5a2e16cd5da08/hf_xet-1.6.0-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl + name: hf-xet + version: 1.6.0 + sha256: d62671bb130879cef0ee4c9ebe47a14af6c66ec53e6d84dc15936e5ffdfac82f + requires_dist: + - pytest ; extra == 'tests' + requires_python: '>=3.8' +- pypi: https://files.pythonhosted.org/packages/68/2f/f24b039169db474e8688f649377de082a965fbf85daf4e46c44412f1d15a/torchvision-0.25.0-cp312-cp312-manylinux_2_28_x86_64.whl + name: torchvision + version: 0.25.0 + sha256: f25aa9e380865b11ea6e9d99d84df86b9cc959f1a007cd966fc6f1ab2ed0e248 + requires_dist: + - numpy + - torch==2.10.0 + - pillow>=5.3.0,!=8.3.* + - gdown>=4.7.3 ; extra == 'gdown' + - scipy ; extra == 'scipy' + requires_python: '>=3.10' +- pypi: https://files.pythonhosted.org/packages/6a/bd/d91c5e39f490a49df14320f4e8c80161cfcce09f1e2cde1edd16a551abb3/frozenlist-1.8.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl + name: frozenlist + version: 1.8.0 + sha256: 494a5952b1c597ba44e0e78113a7266e656b9794eec897b19ead706bd7074383 + requires_python: '>=3.9' +- pypi: https://files.pythonhosted.org/packages/6d/98/bbeb760852adb27f166ce1617f0e51aabb15f21b1e60ea703f2aed3c78ac/pynput-1.8.2-py2.py3-none-any.whl + name: pynput + version: 1.8.2 + sha256: 8cc38cf13a6ab2749cb375678be8a0fd705d7ce49c8001ff5db4007a723bbef1 + requires_dist: + - six + - pyobjc-framework-applicationservices>=8.0 ; sys_platform == 'darwin' + - pyobjc-framework-quartz>=8.0 ; sys_platform == 'darwin' + - evdev>=1.3 ; 'linux' in sys_platform + - python-xlib>=0.17 ; 'linux' in sys_platform + - enum34 ; python_full_version == '2.7.*' +- pypi: https://files.pythonhosted.org/packages/6e/89/f7a07dc961b60645dbbf42e80f2bc85ade7feb9a491b11a1e973aa00071f/nvidia_nccl_cu12-2.27.5-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl + name: nvidia-nccl-cu12 + version: 2.27.5 + sha256: ad730cf15cb5d25fe849c6e6ca9eb5b76db16a80f13f425ac68d8e2e55624457 + requires_python: '>=3' +- pypi: https://files.pythonhosted.org/packages/70/c8/1b758bd903afee000f023cd03f335ff328a21b3914f9f9deda49b1e57723/wandb-0.24.2-py3-none-manylinux_2_28_x86_64.whl + name: wandb + version: 0.24.2 + sha256: 38661c666e70d7e1f460fc0a0edab8a393eaaa5f8773c17be534961a7022779d + requires_dist: + - click>=8.0.1 + - eval-type-backport ; python_full_version < '3.10' + - gitpython>=1.0.0,!=3.1.29 + - packaging + - platformdirs + - protobuf>=3.12.0,!=4.21.0,!=5.28.0,<7 ; python_full_version < '3.9' and sys_platform == 'linux' + - protobuf>=3.15.0,!=4.21.0,!=5.28.0,<7 ; python_full_version == '3.9.*' and sys_platform == 'linux' + - protobuf>=3.19.0,!=4.21.0,!=5.28.0,<7 ; python_full_version >= '3.10' and sys_platform == 'linux' + - protobuf>=3.19.0,!=4.21.0,!=5.28.0,<7 ; sys_platform != 'linux' + - pydantic<3 + - pyyaml + - requests>=2.0.0,<3 + - sentry-sdk>=2.0.0 + - typing-extensions>=4.8,<5 + - boto3 ; extra == 'aws' + - botocore>=1.5.76 ; extra == 'aws' + - azure-identity ; extra == 'azure' + - azure-storage-blob ; extra == 'azure' + - google-cloud-storage ; extra == 'gcp' + - filelock ; extra == 'importers' + - mlflow ; extra == 'importers' + - polars<=1.2.1 ; extra == 'importers' + - rich ; extra == 'importers' + - tenacity ; extra == 'importers' + - google-cloud-storage ; extra == 'kubeflow' + - kubernetes ; extra == 'kubeflow' + - minio ; extra == 'kubeflow' + - sh ; extra == 'kubeflow' + - awscli ; extra == 'launch' + - azure-containerregistry ; extra == 'launch' + - azure-identity ; extra == 'launch' + - azure-storage-blob ; extra == 'launch' + - boto3 ; extra == 'launch' + - botocore>=1.5.76 ; extra == 'launch' + - chardet ; extra == 'launch' + - google-auth ; extra == 'launch' + - google-cloud-aiplatform ; extra == 'launch' + - google-cloud-artifact-registry ; extra == 'launch' + - google-cloud-compute ; extra == 'launch' + - google-cloud-storage ; extra == 'launch' + - iso8601 ; extra == 'launch' + - jsonschema ; extra == 'launch' + - kubernetes ; extra == 'launch' + - kubernetes-asyncio ; extra == 'launch' + - nbconvert ; extra == 'launch' + - nbformat ; extra == 'launch' + - optuna ; extra == 'launch' + - pydantic ; extra == 'launch' + - pyyaml>=6.0.0 ; extra == 'launch' + - tomli ; extra == 'launch' + - tornado>=6.5.0 ; python_full_version >= '3.9' and extra == 'launch' + - typing-extensions ; extra == 'launch' + - bokeh ; extra == 'media' + - imageio>=2.28.1 ; extra == 'media' + - moviepy>=1.0.0 ; extra == 'media' + - numpy ; extra == 'media' + - pillow ; extra == 'media' + - plotly>=5.18.0 ; extra == 'media' + - rdkit ; extra == 'media' + - soundfile ; extra == 'media' + - cloudpickle ; extra == 'models' + - orjson ; extra == 'perf' + - sweeps>=0.2.0 ; extra == 'sweeps' + - wandb-workspaces ; extra == 'workspaces' + requires_python: '>=3.8' +- pypi: https://files.pythonhosted.org/packages/71/43/1947f06babed6b3f1d7f38b0c767f52df66bfb2bc10b468c4a7de9eceff2/aiohappyeyeballs-2.7.1-py3-none-any.whl + name: aiohappyeyeballs + version: 2.7.1 + sha256: 9243213661e29250eb41368e5daa826fc017156c3b8a11440826b2e3ed376472 + requires_python: '>=3.10' +- pypi: https://files.pythonhosted.org/packages/71/70/38998b950a97ea279e6bd657575d22d1a2047256caf707d9a10fbce4f065/multiprocess-0.70.19-py312-none-any.whl + name: multiprocess + version: 0.70.19 + sha256: 3a56c0e85dd5025161bac5ce138dcac1e49174c7d8e74596537e729fd5c53c28 + requires_dist: + - dill>=0.4.1 + requires_python: '>=3.9' +- pypi: https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl + name: mypy-extensions + version: 1.1.0 + sha256: 1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505 + requires_python: '>=3.8' +- pypi: https://files.pythonhosted.org/packages/7c/f0/21f81892e4ed10f4ec3ef2e7cf8635fb76e7c0907c55d0da66be50094760/farama_notifications-0.0.6-py3-none-any.whl + name: farama-notifications + version: 0.0.6 + sha256: f84839188efa1ce5bb361c2a84881b2dc2c0d0d7fb661ff00421820170930935 - pypi: https://files.pythonhosted.org/packages/7d/0a/2e0c30342586dabb0b4b7946393a7aeb725fca7b4549fb02e128a1a0fe87/py_trees-2.4.0-py3-none-any.whl name: py-trees version: 2.4.0 @@ -50049,6 +52265,11 @@ packages: requires_dist: - pydot>=1.4 requires_python: '>=3.9,<4.0' +- pypi: https://files.pythonhosted.org/packages/7d/68/d8d58938dfb1370b266a1a729e6d77a985be23689a0496498ee17b2cbf90/platformdirs-4.11.0-py3-none-any.whl + name: platformdirs + version: 4.11.0 + sha256: 360ccded2b7fce0af0ff80cc8f5942a1c5d99b0e856033acb030bfc634709e74 + requires_python: '>=3.10' - pypi: https://files.pythonhosted.org/packages/7e/32/a7125fb28c4261a627f999d5fb4afff25b523800faed2c30979949d6facd/pydot-4.0.1-py3-none-any.whl name: pydot version: 4.0.1 @@ -50068,6 +52289,18 @@ packages: - pytest-xdist[psutil] ; extra == 'tests' - zest-releaser[recommended] ; extra == 'release' requires_python: '>=3.8' +- pypi: https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl + name: httpcore + version: 1.0.9 + sha256: 2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55 + requires_dist: + - certifi + - h11>=0.16 + - anyio>=4.0,<5.0 ; extra == 'asyncio' + - h2>=3,<5 ; extra == 'http2' + - socksio==1.* ; extra == 'socks' + - trio>=0.22.0,<1.0 ; extra == 'trio' + requires_python: '>=3.8' - pypi: https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl name: urllib3 version: 2.7.0 @@ -50079,11 +52312,164 @@ packages: - pysocks>=1.5.6,!=1.5.7,<2.0 ; extra == 'socks' - backports-zstd>=1.0.0 ; python_full_version < '3.14' and extra == 'zstd' requires_python: '>=3.10' +- pypi: https://files.pythonhosted.org/packages/85/48/9a13d2975803e8cf2777d5ed57b87a0b6ca2cc795f9a4f59796a910bfb80/nvidia_cusolver_cu12-11.7.3.90-py3-none-manylinux_2_27_x86_64.whl + name: nvidia-cusolver-cu12 + version: 11.7.3.90 + sha256: 4376c11ad263152bd50ea295c05370360776f8c3427b30991df774f9fb26c450 + requires_dist: + - nvidia-cublas-cu12 + - nvidia-nvjitlink-cu12 + - nvidia-cusparse-cu12 + requires_python: '>=3' +- pypi: https://files.pythonhosted.org/packages/88/39/799be3f2f0f38cc727ee3b4f1445fe6d5e4133064ec2e4115069418a5bb6/cloudpickle-3.1.2-py3-none-any.whl + name: cloudpickle + version: 3.1.2 + sha256: 9acb47f6afd73f60dc1df93bb801b472f05ff42fa6c84167d25cb206be1fbf4a + requires_python: '>=3.8' +- pypi: https://files.pythonhosted.org/packages/89/d3/726bd88f0eece09ddf431bea4c9191c18e7a8d070b854eb0014d447712ee/sentry_sdk-2.66.1-py3-none-any.whl + name: sentry-sdk + version: 2.66.1 + sha256: 86002793161d9a95ef04bdd8d442e9bfece5d989b755f05d6360215094a7aff6 + requires_dist: + - urllib3>=1.26.11 + - certifi + - aiohttp>=3.5 ; extra == 'aiohttp' + - anthropic>=0.16 ; extra == 'anthropic' + - arq>=0.23 ; extra == 'arq' + - asyncpg>=0.23 ; extra == 'asyncpg' + - apache-beam>=2.12 ; extra == 'beam' + - bottle>=0.12.13 ; extra == 'bottle' + - celery>=3 ; extra == 'celery' + - celery-redbeat>=2 ; extra == 'celery-redbeat' + - chalice>=1.16.0 ; extra == 'chalice' + - clickhouse-driver>=0.2.0 ; extra == 'clickhouse-driver' + - django>=1.8 ; extra == 'django' + - falcon>=1.4 ; extra == 'falcon' + - fastapi>=0.79.0 ; extra == 'fastapi' + - flask>=0.11 ; extra == 'flask' + - blinker>=1.1 ; extra == 'flask' + - markupsafe ; extra == 'flask' + - grpcio>=1.21.1 ; extra == 'grpcio' + - protobuf>=3.8.0 ; extra == 'grpcio' + - httpcore[http2]==1.* ; extra == 'http2' + - httpcore[asyncio]==1.* ; extra == 'asyncio' + - httpx>=0.16.0 ; extra == 'httpx' + - huey>=2 ; extra == 'huey' + - huggingface-hub>=0.22 ; extra == 'huggingface-hub' + - langchain>=0.0.210 ; extra == 'langchain' + - langgraph>=0.6.6 ; extra == 'langgraph' + - launchdarkly-server-sdk>=9.8.0 ; extra == 'launchdarkly' + - litellm>=1.77.5,!=1.82.7,!=1.82.8 ; extra == 'litellm' + - litestar>=2.0.0 ; extra == 'litestar' + - loguru>=0.5 ; extra == 'loguru' + - mcp>=1.15.0 ; extra == 'mcp' + - openai>=1.0.0 ; extra == 'openai' + - tiktoken>=0.3.0 ; extra == 'openai' + - openfeature-sdk>=0.7.1 ; extra == 'openfeature' + - opentelemetry-distro>=0.35b0 ; extra == 'opentelemetry' + - opentelemetry-distro ; extra == 'opentelemetry-experimental' + - opentelemetry-distro[otlp]>=0.35b0 ; extra == 'opentelemetry-otlp' + - pure-eval ; extra == 'pure-eval' + - executing ; extra == 'pure-eval' + - asttokens ; extra == 'pure-eval' + - pydantic-ai>=1.0.0 ; extra == 'pydantic-ai' + - pymongo>=3.1 ; extra == 'pymongo' + - pyspark>=2.4.4 ; extra == 'pyspark' + - quart>=0.16.1 ; extra == 'quart' + - blinker>=1.1 ; extra == 'quart' + - rq>=0.6 ; extra == 'rq' + - sanic>=0.8 ; extra == 'sanic' + - sqlalchemy>=1.2 ; extra == 'sqlalchemy' + - starlette>=0.19.1 ; extra == 'starlette' + - starlite>=1.48 ; extra == 'starlite' + - statsig>=0.55.3 ; extra == 'statsig' + - tornado>=6 ; extra == 'tornado' + - unleashclient>=6.0.1 ; extra == 'unleash' + - google-genai>=1.29.0 ; extra == 'google-genai' + requires_python: '>=3.6' - pypi: https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl name: pyyaml version: 6.0.3 sha256: ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc requires_python: '>=3.8' +- pypi: https://files.pythonhosted.org/packages/8c/d7/8ff98376b1acc4503253b685ea09981697385ce344d4e3935c2af49e044d/pfzy-0.3.4-py3-none-any.whl + name: pfzy + version: 0.3.4 + sha256: 5f50d5b2b3207fa72e7ec0ef08372ef652685470974a107d0d4999fc5a903a96 + requires_dist: + - sphinx>=4.1.2,<5.0.0 ; extra == 'docs' + - furo>=2021.8.17b43,<2022.0.0 ; extra == 'docs' + - myst-parser>=0.15.1,<0.16.0 ; extra == 'docs' + - sphinx-autobuild>=2021.3.14,<2022.0.0 ; extra == 'docs' + - sphinx-copybutton>=0.4.0,<0.5.0 ; extra == 'docs' + requires_python: '>=3.7,<4.0' +- pypi: https://files.pythonhosted.org/packages/94/b8/f1f62a5e3c0ad2ff1d189590bfa4c46b4f3b6e49cef6f26c6ee4e575394d/setuptools-80.10.2-py3-none-any.whl + name: setuptools + version: 80.10.2 + sha256: 95b30ddfb717250edb492926c92b5221f7ef3fbcc2b07579bcd4a27da21d0173 + requires_dist: + - pytest>=6,!=8.1.* ; extra == 'test' + - virtualenv>=13.0.0 ; extra == 'test' + - wheel>=0.44.0 ; extra == 'test' + - pip>=19.1 ; extra == 'test' + - packaging>=24.2 ; extra == 'test' + - jaraco-envs>=2.2 ; extra == 'test' + - pytest-xdist>=3 ; extra == 'test' + - jaraco-path>=3.7.2 ; extra == 'test' + - build[virtualenv]>=1.0.3 ; extra == 'test' + - filelock>=3.4.0 ; extra == 'test' + - ini2toml[lite]>=0.14 ; extra == 'test' + - tomli-w>=1.0.0 ; extra == 'test' + - pytest-timeout ; extra == 'test' + - pytest-perf ; sys_platform != 'cygwin' and extra == 'test' + - jaraco-develop>=7.21 ; python_full_version >= '3.9' and sys_platform != 'cygwin' and extra == 'test' + - pytest-home>=0.5 ; extra == 'test' + - pytest-subprocess ; extra == 'test' + - pyproject-hooks!=1.1 ; extra == 'test' + - jaraco-test>=5.5 ; extra == 'test' + - sphinx>=3.5 ; extra == 'doc' + - jaraco-packaging>=9.3 ; extra == 'doc' + - rst-linker>=1.9 ; extra == 'doc' + - furo ; extra == 'doc' + - sphinx-lint ; extra == 'doc' + - jaraco-tidelift>=1.4 ; extra == 'doc' + - pygments-github-lexers==0.0.5 ; extra == 'doc' + - sphinx-favicon ; extra == 'doc' + - sphinx-inline-tabs ; extra == 'doc' + - sphinx-reredirects ; extra == 'doc' + - sphinxcontrib-towncrier ; extra == 'doc' + - sphinx-notfound-page>=1,<2 ; extra == 'doc' + - pyproject-hooks!=1.1 ; extra == 'doc' + - towncrier<24.7 ; extra == 'doc' + - packaging>=24.2 ; extra == 'core' + - more-itertools>=8.8 ; extra == 'core' + - jaraco-text>=3.7 ; extra == 'core' + - importlib-metadata>=6 ; python_full_version < '3.10' and extra == 'core' + - tomli>=2.0.1 ; python_full_version < '3.11' and extra == 'core' + - wheel>=0.43.0 ; extra == 'core' + - platformdirs>=4.2.2 ; extra == 'core' + - jaraco-functools>=4 ; extra == 'core' + - more-itertools ; extra == 'core' + - pytest-checkdocs>=2.4 ; extra == 'check' + - pytest-ruff>=0.2.1 ; sys_platform != 'cygwin' and extra == 'check' + - ruff>=0.8.0 ; sys_platform != 'cygwin' and extra == 'check' + - pytest-cov ; extra == 'cover' + - pytest-enabler>=2.2 ; extra == 'enabler' + - pytest-mypy ; extra == 'type' + - mypy==1.14.* ; extra == 'type' + - importlib-metadata>=7.0.2 ; python_full_version < '3.10' and extra == 'type' + - jaraco-develop>=7.21 ; sys_platform != 'cygwin' and extra == 'type' + requires_python: '>=3.9' +- pypi: https://files.pythonhosted.org/packages/96/42/3e5985a0a7e57de470b320c6d6a1a67c844f6737a587f3d44dd13d1819e7/wcwidth-0.8.2-py3-none-any.whl + name: wcwidth + version: 0.8.2 + sha256: d63947694a0539a1d51e01eda7caf800c291020e6cdd7e28ad7b14dd33ad4f85 + requires_python: '>=3.8' +- pypi: https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl + name: annotated-types + version: 0.8.0 + sha256: f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0 + requires_python: '>=3.10' - pypi: https://files.pythonhosted.org/packages/9e/c9/b2622292ea83fbb4ec318f5b9ab867d0a28ab43c5717bb85b0a5f6b3b0a4/networkx-3.6.1-py3-none-any.whl name: networkx version: 3.6.1 @@ -50127,6 +52513,18 @@ packages: - pytest-mpl ; extra == 'test-extras' - pytest-randomly ; extra == 'test-extras' requires_python: '>=3.11,!=3.14.1' +- pypi: https://files.pythonhosted.org/packages/a0/2d/43c8522a2038e9d0e7dbdf3a61195ecc31ca576fb1527a528c877e87d973/imageio_ffmpeg-0.6.0-py3-none-manylinux2014_x86_64.whl + name: imageio-ffmpeg + version: 0.6.0 + sha256: c7e46fcec401dd990405049d2e2f475e2b397779df2519b544b8aab515195282 + requires_python: '>=3.9' +- pypi: https://files.pythonhosted.org/packages/a0/61/5c78b91c3143ed5c14207f463aecfc8f9dbb5092fb2869baf37c273b2705/gitdb-4.0.12-py3-none-any.whl + name: gitdb + version: 4.0.12 + sha256: 67073e15955400952c6565cc3e707c554a4eea2e428946f7a4c162fab9bd9bcf + requires_dist: + - smmap>=3.0.1,<6 + requires_python: '>=3.7' - pypi: https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl name: requests version: 2.34.2 @@ -50148,6 +52546,16 @@ packages: - pytest>=7.1.0 ; extra == 'dev' - hypothesis>=6.70.0 ; extra == 'dev' requires_python: '>=3.9' +- pypi: https://files.pythonhosted.org/packages/a2/eb/86626c1bbc2edb86323022371c39aa48df6fd8b0a1647bc274577f72e90b/nvidia_nvtx_cu12-12.8.90-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl + name: nvidia-nvtx-cu12 + version: 12.8.90 + sha256: 5b17e2001cc0d751a5bc2c6ec6d26ad95913324a4adb86788c944f8ce9ba441f + requires_python: '>=3' +- pypi: https://files.pythonhosted.org/packages/a5/f5/397b61091120a9ca5001041dd7bf76c385b3bfd67a0e5bcb74b852bd22a4/evdev-1.9.3.tar.gz + name: evdev + version: 1.9.3 + sha256: 2c140e01ac8437758fa23fe5c871397412461f42d421aa20241dc8fe8cfccbc9 + requires_python: '>=3.9' - pypi: https://files.pythonhosted.org/packages/a8/af/48ac8483240de756d2438c380746e7130d1c6f75802ef22f3c6d49982787/huggingface_hub-0.36.2-py3-none-any.whl name: huggingface-hub version: 0.36.2 @@ -50285,6 +52693,394 @@ packages: - types-tqdm ; extra == 'dev' - types-urllib3 ; extra == 'dev' requires_python: '>=3.8.0' +- pypi: https://files.pythonhosted.org/packages/a8/db/253133d7e7cb40d3af384bb2f5c0b4a2b7fdcffbc95c688cc67a20a3c103/accelerate-1.14.0-py3-none-any.whl + name: accelerate + version: 1.14.0 + sha256: e94390c2863b873be18f623f9df48a0d8fe5eff13ea7f1a00092b0a7904888c6 + requires_dist: + - numpy>=1.17 + - packaging>=20.0 + - psutil + - pyyaml + - torch>=2.0.0 + - huggingface-hub>=0.21.0 + - safetensors>=0.4.3 + - ruff==0.13.1 ; extra == 'quality' + - pytest>=7.2.0 ; extra == 'test-prod' + - pytest-xdist ; extra == 'test-prod' + - pytest-subtests ; extra == 'test-prod' + - parameterized ; extra == 'test-prod' + - pytest-order ; extra == 'test-prod' + - datasets ; extra == 'test-dev' + - diffusers ; extra == 'test-dev' + - evaluate ; extra == 'test-dev' + - torchdata>=0.8.0 ; extra == 'test-dev' + - torchpippy>=0.2.0 ; extra == 'test-dev' + - transformers ; extra == 'test-dev' + - scipy ; extra == 'test-dev' + - scikit-learn ; extra == 'test-dev' + - tqdm ; extra == 'test-dev' + - bitsandbytes ; extra == 'test-dev' + - timm ; extra == 'test-dev' + - pytest>=7.2.0 ; extra == 'testing' + - pytest-xdist ; extra == 'testing' + - pytest-subtests ; extra == 'testing' + - parameterized ; extra == 'testing' + - pytest-order ; extra == 'testing' + - datasets ; extra == 'testing' + - diffusers ; extra == 'testing' + - evaluate ; extra == 'testing' + - torchdata>=0.8.0 ; extra == 'testing' + - torchpippy>=0.2.0 ; extra == 'testing' + - transformers ; extra == 'testing' + - scipy ; extra == 'testing' + - scikit-learn ; extra == 'testing' + - tqdm ; extra == 'testing' + - bitsandbytes ; extra == 'testing' + - timm ; extra == 'testing' + - deepspeed ; extra == 'deepspeed' + - rich ; extra == 'rich' + - torchao ; extra == 'test-fp8' + - wandb ; extra == 'test-trackers' + - tensorboard ; extra == 'test-trackers' + - dvclive ; extra == 'test-trackers' + - matplotlib ; extra == 'test-trackers' + - swanlab[dashboard] ; extra == 'test-trackers' + - trackio ; extra == 'test-trackers' + - ruff==0.13.1 ; extra == 'dev' + - pytest>=7.2.0 ; extra == 'dev' + - pytest-xdist ; extra == 'dev' + - pytest-subtests ; extra == 'dev' + - parameterized ; extra == 'dev' + - pytest-order ; extra == 'dev' + - datasets ; extra == 'dev' + - diffusers ; extra == 'dev' + - evaluate ; extra == 'dev' + - torchdata>=0.8.0 ; extra == 'dev' + - torchpippy>=0.2.0 ; extra == 'dev' + - transformers ; extra == 'dev' + - scipy ; extra == 'dev' + - scikit-learn ; extra == 'dev' + - tqdm ; extra == 'dev' + - bitsandbytes ; extra == 'dev' + - timm ; extra == 'dev' + - rich ; extra == 'dev' + - sagemaker ; extra == 'sagemaker' + requires_python: '>=3.10.0' +- pypi: https://files.pythonhosted.org/packages/a9/c1/dabe88f52c3e3760d861401bb994df08f672ec893b8f7592dc91626adcf3/cuda_bindings-12.9.4-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl + name: cuda-bindings + version: 12.9.4 + sha256: fda147a344e8eaeca0c6ff113d2851ffca8f7dfc0a6c932374ee5c47caa649c8 + requires_dist: + - cuda-pathfinder~=1.1 + - nvidia-cuda-nvcc-cu12 ; extra == 'all' + - nvidia-cuda-nvrtc-cu12 ; extra == 'all' + - nvidia-nvjitlink-cu12>=12.3 ; extra == 'all' + - nvidia-cufile-cu12 ; sys_platform == 'linux' and extra == 'all' + - cython>=3.1,<3.2 ; extra == 'test' + - setuptools>=77.0.0 ; extra == 'test' + - numpy>=1.21.1 ; extra == 'test' + - pytest>=6.2.4 ; extra == 'test' + - pytest-benchmark>=3.4.1 ; extra == 'test' + - pyglet>=2.1.9 ; extra == 'test' +- pypi: https://files.pythonhosted.org/packages/ab/a8/cdf8b3e4c98132f965f88c2313a4b493266832ad47fb52f23d14d4f86bb5/triton-3.6.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl + name: triton + version: 3.6.0 + sha256: 74caf5e34b66d9f3a429af689c1c7128daba1d8208df60e81106b115c00d6fca + requires_dist: + - importlib-metadata ; python_full_version < '3.10' + - cmake>=3.20,<4.0 ; extra == 'build' + - lit ; extra == 'build' + - autopep8 ; extra == 'tests' + - isort ; extra == 'tests' + - numpy ; extra == 'tests' + - pytest ; extra == 'tests' + - pytest-forked ; extra == 'tests' + - pytest-xdist ; extra == 'tests' + - scipy>=1.7.1 ; extra == 'tests' + - llnl-hatchet ; extra == 'tests' + - matplotlib ; extra == 'tutorials' + - pandas ; extra == 'tutorials' + - tabulate ; extra == 'tutorials' + requires_python: '>=3.10,<3.15' +- pypi: https://files.pythonhosted.org/packages/ac/13/37737ef2193e83862ccacff23580c39de251da456a1bf0459e762cca273c/av-15.1.0-cp312-cp312-manylinux_2_28_x86_64.whl + name: av + version: 15.1.0 + sha256: 11326f197e7001c4ca53a83b2dbc67fd39ddff8cdf62ce6be3b22d9f3f9338bd + requires_python: '>=3.9' +- pypi: https://files.pythonhosted.org/packages/b3/7a/abada41517ce0011775f0f4eacc79659bc9bc6c361e6bfe6f7052a6b9363/torch-2.10.0-3-cp312-cp312-manylinux_2_28_x86_64.whl + name: torch + version: 2.10.0 + sha256: 98c01b8bb5e3240426dcde1446eed6f40c778091c8544767ef1168fc663a05a6 + requires_dist: + - filelock + - typing-extensions>=4.10.0 + - setuptools ; python_full_version >= '3.12' + - sympy>=1.13.3 + - networkx>=2.5.1 + - jinja2 + - fsspec>=0.8.5 + - cuda-bindings==12.9.4 ; platform_machine == 'x86_64' and sys_platform == 'linux' + - nvidia-cuda-nvrtc-cu12==12.8.93 ; platform_machine == 'x86_64' and sys_platform == 'linux' + - nvidia-cuda-runtime-cu12==12.8.90 ; platform_machine == 'x86_64' and sys_platform == 'linux' + - nvidia-cuda-cupti-cu12==12.8.90 ; platform_machine == 'x86_64' and sys_platform == 'linux' + - nvidia-cudnn-cu12==9.10.2.21 ; platform_machine == 'x86_64' and sys_platform == 'linux' + - nvidia-cublas-cu12==12.8.4.1 ; platform_machine == 'x86_64' and sys_platform == 'linux' + - nvidia-cufft-cu12==11.3.3.83 ; platform_machine == 'x86_64' and sys_platform == 'linux' + - nvidia-curand-cu12==10.3.9.90 ; platform_machine == 'x86_64' and sys_platform == 'linux' + - nvidia-cusolver-cu12==11.7.3.90 ; platform_machine == 'x86_64' and sys_platform == 'linux' + - nvidia-cusparse-cu12==12.5.8.93 ; platform_machine == 'x86_64' and sys_platform == 'linux' + - nvidia-cusparselt-cu12==0.7.1 ; platform_machine == 'x86_64' and sys_platform == 'linux' + - nvidia-nccl-cu12==2.27.5 ; platform_machine == 'x86_64' and sys_platform == 'linux' + - nvidia-nvshmem-cu12==3.4.5 ; platform_machine == 'x86_64' and sys_platform == 'linux' + - nvidia-nvtx-cu12==12.8.90 ; platform_machine == 'x86_64' and sys_platform == 'linux' + - nvidia-nvjitlink-cu12==12.8.93 ; platform_machine == 'x86_64' and sys_platform == 'linux' + - nvidia-cufile-cu12==1.13.1.3 ; platform_machine == 'x86_64' and sys_platform == 'linux' + - triton==3.6.0 ; platform_machine == 'x86_64' and sys_platform == 'linux' + - optree>=0.13.0 ; extra == 'optree' + - opt-einsum>=3.3 ; extra == 'opt-einsum' + - pyyaml ; extra == 'pyyaml' + requires_python: '>=3.10' +- pypi: https://files.pythonhosted.org/packages/b5/09/6ea3ea725f82e1e76684f0708bbedd871fc96da89945adeba65c3835a64c/nvidia_nvshmem_cu12-3.4.5-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl + name: nvidia-nvshmem-cu12 + version: 3.4.5 + sha256: 042f2500f24c021db8a06c5eec2539027d57460e1c1a762055a6554f72c369bd + requires_python: '>=3' +- pypi: https://files.pythonhosted.org/packages/b5/70/5d8df3b09e25bce090399cf48e452d25c935ab72dad19406c77f4e828045/psutil-7.2.2-cp36-abi3-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl + name: psutil + version: 7.2.2 + sha256: 076a2d2f923fd4821644f5ba89f059523da90dc9014e85f8e45a5774ca5bc6f9 + requires_dist: + - psleak ; extra == 'dev' + - pytest ; extra == 'dev' + - pytest-instafail ; extra == 'dev' + - pytest-xdist ; extra == 'dev' + - setuptools ; extra == 'dev' + - abi3audit ; extra == 'dev' + - black ; extra == 'dev' + - check-manifest ; extra == 'dev' + - coverage ; extra == 'dev' + - packaging ; extra == 'dev' + - pylint ; extra == 'dev' + - pyperf ; extra == 'dev' + - pypinfo ; extra == 'dev' + - pytest-cov ; extra == 'dev' + - requests ; extra == 'dev' + - rstcheck ; extra == 'dev' + - ruff ; extra == 'dev' + - sphinx ; extra == 'dev' + - sphinx-rtd-theme ; extra == 'dev' + - toml-sort ; extra == 'dev' + - twine ; extra == 'dev' + - validate-pyproject[all] ; extra == 'dev' + - virtualenv ; extra == 'dev' + - vulture ; extra == 'dev' + - wheel ; extra == 'dev' + - colorama ; os_name == 'nt' and extra == 'dev' + - pyreadline3 ; os_name == 'nt' and extra == 'dev' + - pywin32 ; implementation_name != 'pypy' and os_name == 'nt' and extra == 'dev' + - wheel ; implementation_name != 'pypy' and os_name == 'nt' and extra == 'dev' + - wmi ; implementation_name != 'pypy' and os_name == 'nt' and extra == 'dev' + - psleak ; extra == 'test' + - pytest ; extra == 'test' + - pytest-instafail ; extra == 'test' + - pytest-xdist ; extra == 'test' + - setuptools ; extra == 'test' + - pywin32 ; implementation_name != 'pypy' and os_name == 'nt' and extra == 'test' + - wheel ; implementation_name != 'pypy' and os_name == 'nt' and extra == 'test' + - wmi ; implementation_name != 'pypy' and os_name == 'nt' and extra == 'test' + requires_python: '>=3.6' +- pypi: https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl + name: six + version: 1.17.0 + sha256: 4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274 + requires_python: '>=2.7,!=3.0.*,!=3.1.*,!=3.2.*' +- pypi: https://files.pythonhosted.org/packages/ba/51/e123d997aa098c61d029f76663dedbfb9bc8dcf8c60cbd6adbe42f76d049/nvidia_cudnn_cu12-9.10.2.21-py3-none-manylinux_2_27_x86_64.whl + name: nvidia-cudnn-cu12 + version: 9.10.2.21 + sha256: 949452be657fa16687d0930933f032835951ef0892b37d2d53824d1a84dc97a8 + requires_dist: + - nvidia-cublas-cu12 + requires_python: '>=3' +- pypi: https://files.pythonhosted.org/packages/bb/fe/1bcba1dfbfb8d01be8d93f07bfc502c93fa23afa6fd5ab3fc7c1df71038a/nvidia_cufile_cu12-1.13.1.3-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl + name: nvidia-cufile-cu12 + version: 1.13.1.3 + sha256: 1d069003be650e131b21c932ec3d8969c1715379251f8d23a1860554b1cb24fc + requires_python: '>=3' +- pypi: https://files.pythonhosted.org/packages/c1/d4/59e74daffcb57a07668852eeeb6035af9f32cbfd7a1d2511f17d2fe6a738/smmap-5.0.3-py3-none-any.whl + name: smmap + version: 5.0.3 + sha256: c106e05d5a61449cf6ba9a1e650227ecfb141590d2a98412103ff35d89fc7b2f + requires_python: '>=3.7' +- pypi: https://files.pythonhosted.org/packages/c1/e8/72f8cef9fdfeffe06213fe8508039396ee48daa0e3259457ed766173bfd6/filelock-3.32.2-py3-none-any.whl + name: filelock + version: 3.32.2 + sha256: 87dd94cf281e586d135fa51132b8e3d9a598b316e90377a288663c9321036c82 + requires_python: '>=3.10' +- pypi: https://files.pythonhosted.org/packages/c2/f5/e1854cb2f2bcd4280c44736c93550cc300ff4b8c95ebe370d0aa7d2b473d/nvidia_cusparse_cu12-12.5.8.93-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl + name: nvidia-cusparse-cu12 + version: 12.5.8.93 + sha256: 1ec05d76bbbd8b61b06a80e1eaf8cf4959c3d4ce8e711b65ebd0443bb0ebb13b + requires_dist: + - nvidia-nvjitlink-cu12 + requires_python: '>=3' +- pypi: https://files.pythonhosted.org/packages/c4/9a/a83083b230d352ee5d205757b74006dbe084448ca45e3bc5ca99215b1e55/draccus-0.10.0-py3-none-any.whl + name: draccus + version: 0.10.0 + sha256: 90243418ae0e9271c390a59cafb6acfd37001193696ed36fcc8525f791a83282 + requires_dist: + - mergedeep~=1.3 + - pyyaml~=6.0 + - pyyaml-include~=1.4 + - toml~=0.10 + - typing-inspect~=0.9.0 + - black ; extra == 'dev' + - mypy ; extra == 'dev' + - pre-commit ; extra == 'dev' + - pytest ; extra == 'dev' + - ruff ; extra == 'dev' + requires_python: '>=3.9' +- pypi: https://files.pythonhosted.org/packages/cb/2d/2c43b7d99346b04925313f485b8f99596aeb8094f556d4312da9f2e1ca60/lerobot-0.4.4-py3-none-any.whl + name: lerobot + version: 0.4.4 + sha256: e53800ead8216861540ad3aebaf12e3cf87a399b3c1f234eeead33716c9c24fd + requires_dist: + - datasets>=4.0.0,<5.0.0 + - diffusers>=0.27.2,<0.36.0 + - huggingface-hub[cli,hf-transfer]>=0.34.2,<0.36.0 + - accelerate>=1.10.0,<2.0.0 + - setuptools>=71.0.0,<81.0.0 + - cmake>=3.29.0.1,<4.2.0 + - einops>=0.8.0,<0.9.0 + - opencv-python-headless>=4.9.0,<4.13.0 + - av>=15.0.0,<16.0.0 + - jsonlines>=4.0.0,<5.0.0 + - packaging>=24.2,<26.0 + - pynput>=1.7.7,<1.9.0 + - pyserial>=3.5,<4.0 + - wandb>=0.24.0,<0.25.0 + - torch>=2.2.1,<2.11.0 + - torchcodec>=0.2.1,<0.11.0 ; (platform_machine != 'aarch64' and platform_machine != 'arm64' and platform_machine != 'armv7l' and platform_machine != 'x86_64' and sys_platform != 'win32') or (platform_machine == 'aarch64' and sys_platform != 'linux' and sys_platform != 'win32') or (platform_machine == 'arm64' and sys_platform != 'linux' and sys_platform != 'win32') or (platform_machine == 'armv7l' and sys_platform != 'linux' and sys_platform != 'win32') or (platform_machine == 'x86_64' and sys_platform != 'darwin' and sys_platform != 'win32') + - torchvision>=0.21.0,<0.26.0 + - draccus==0.10.0 + - gymnasium>=1.1.1,<2.0.0 + - rerun-sdk>=0.24.0,<0.27.0 + - deepdiff>=7.0.1,<9.0.0 + - imageio[ffmpeg]>=2.34.0,<3.0.0 + - termcolor>=2.4.0,<4.0.0 + - pygame>=2.5.1,<2.7.0 ; extra == 'pygame-dep' + - placo>=0.9.6,<0.10.0 ; extra == 'placo-dep' + - transformers>=4.57.1,<5.0.0 ; extra == 'transformers-dep' + - grpcio==1.73.1 ; extra == 'grpcio-dep' + - protobuf>=6.31.1,<6.32.0 ; extra == 'grpcio-dep' + - python-can>=4.2.0,<5.0.0 ; extra == 'can-dep' + - feetech-servo-sdk>=1.0.0,<2.0.0 ; extra == 'feetech' + - dynamixel-sdk>=3.7.31,<3.9.0 ; extra == 'dynamixel' + - lerobot[can-dep] ; extra == 'damiao' + - lerobot[can-dep] ; extra == 'robstride' + - lerobot[damiao] ; extra == 'openarms' + - lerobot[pygame-dep] ; extra == 'gamepad' + - hidapi>=0.14.0,<0.15.0 ; extra == 'gamepad' + - lerobot[feetech] ; extra == 'hopejr' + - lerobot[pygame-dep] ; extra == 'hopejr' + - lerobot[feetech] ; extra == 'lekiwi' + - pyzmq>=26.2.1,<28.0.0 ; extra == 'lekiwi' + - pyzmq>=26.2.1,<28.0.0 ; extra == 'unitree-g1' + - onnxruntime>=1.16.0,<2.0.0 ; extra == 'unitree-g1' + - pin>=3.0.0,<4.0.0 ; extra == 'unitree-g1' + - meshcat>=0.3.0,<0.4.0 ; extra == 'unitree-g1' + - matplotlib>=3.9.0,<4.0.0 ; extra == 'unitree-g1' + - casadi>=3.6.0,<4.0.0 ; extra == 'unitree-g1' + - reachy2-sdk>=1.0.15,<1.1.0 ; extra == 'reachy2' + - lerobot[placo-dep] ; extra == 'kinematics' + - pyrealsense2>=2.55.1.6486,<2.57.0 ; sys_platform != 'darwin' and extra == 'intelrealsense' + - pyrealsense2-macosx>=2.54,<2.55.0 ; sys_platform == 'darwin' and extra == 'intelrealsense' + - hebi-py>=2.8.0,<2.12.0 ; extra == 'phone' + - teleop>=0.1.0,<0.2.0 ; extra == 'phone' + - fastapi<1.0 ; extra == 'phone' + - transformers==4.49.0 ; extra == 'wallx' + - peft==0.17.1 ; extra == 'wallx' + - scipy==1.15.3 ; extra == 'wallx' + - torchdiffeq==0.2.5 ; extra == 'wallx' + - qwen-vl-utils==0.0.11 ; extra == 'wallx' + - lerobot[transformers-dep] ; extra == 'smolvla' + - num2words>=0.5.14,<0.6.0 ; extra == 'smolvla' + - accelerate>=1.7.0,<2.0.0 ; extra == 'smolvla' + - safetensors>=0.4.3,<1.0.0 ; extra == 'smolvla' + - lerobot[transformers-dep] ; extra == 'groot' + - peft>=0.13.0,<1.0.0 ; extra == 'groot' + - dm-tree>=0.1.8,<1.0.0 ; extra == 'groot' + - timm>=1.0.0,<1.1.0 ; extra == 'groot' + - safetensors>=0.4.3,<1.0.0 ; extra == 'groot' + - pillow>=10.0.0,<13.0.0 ; extra == 'groot' + - decord>=0.6.0,<1.0.0 ; (platform_machine == 'AMD64' and extra == 'groot') or (platform_machine == 'x86_64' and extra == 'groot') + - ninja>=1.11.1,<2.0.0 ; extra == 'groot' + - flash-attn>=2.5.9,<3.0.0 ; sys_platform != 'darwin' and extra == 'groot' + - lerobot[transformers-dep] ; extra == 'sarm' + - faker>=33.0.0,<35.0.0 ; extra == 'sarm' + - matplotlib>=3.10.3,<4.0.0 ; extra == 'sarm' + - qwen-vl-utils>=0.0.14,<0.1.0 ; extra == 'sarm' + - lerobot[transformers-dep] ; extra == 'xvla' + - lerobot[transformers-dep] ; extra == 'hilserl' + - gym-hil>=0.1.13,<0.2.0 ; extra == 'hilserl' + - lerobot[grpcio-dep] ; extra == 'hilserl' + - lerobot[placo-dep] ; extra == 'hilserl' + - lerobot[grpcio-dep] ; extra == 'async' + - matplotlib>=3.10.3,<4.0.0 ; extra == 'async' + - lerobot[transformers-dep] ; extra == 'peft' + - peft>=0.18.0,<1.0.0 ; extra == 'peft' + - pre-commit>=3.7.0,<5.0.0 ; extra == 'dev' + - debugpy>=1.8.1,<1.9.0 ; extra == 'dev' + - lerobot[grpcio-dep] ; extra == 'dev' + - grpcio-tools==1.73.1 ; extra == 'dev' + - mypy>=1.19.1 ; extra == 'dev' + - pytest>=8.1.0,<9.0.0 ; extra == 'test' + - pytest-timeout>=2.4.0,<3.0.0 ; extra == 'test' + - pytest-cov>=5.0.0,<8.0.0 ; extra == 'test' + - mock-serial>=0.0.1,<0.1.0 ; sys_platform != 'win32' and extra == 'test' + - scikit-image>=0.23.2,<0.26.0 ; extra == 'video-benchmark' + - pandas>=2.2.2,<2.4.0 ; extra == 'video-benchmark' + - gym-aloha>=0.1.2,<0.2.0 ; extra == 'aloha' + - gym-pusht>=0.1.5,<0.2.0 ; extra == 'pusht' + - pymunk>=6.6.0,<7.0.0 ; extra == 'pusht' + - lerobot[transformers-dep] ; extra == 'libero' + - hf-libero>=0.1.3,<0.2.0 ; extra == 'libero' + - metaworld==3.0.0 ; extra == 'metaworld' + - lerobot[dynamixel] ; extra == 'all' + - lerobot[gamepad] ; extra == 'all' + - lerobot[hopejr] ; extra == 'all' + - lerobot[lekiwi] ; extra == 'all' + - lerobot[reachy2] ; extra == 'all' + - lerobot[kinematics] ; extra == 'all' + - lerobot[intelrealsense] ; extra == 'all' + - lerobot[smolvla] ; extra == 'all' + - lerobot[xvla] ; extra == 'all' + - lerobot[hilserl] ; extra == 'all' + - lerobot[async] ; extra == 'all' + - lerobot[dev] ; extra == 'all' + - lerobot[test] ; extra == 'all' + - lerobot[video-benchmark] ; extra == 'all' + - lerobot[aloha] ; extra == 'all' + - lerobot[pusht] ; extra == 'all' + - lerobot[phone] ; extra == 'all' + - lerobot[libero] ; extra == 'all' + - lerobot[metaworld] ; extra == 'all' + - lerobot[sarm] ; extra == 'all' + - lerobot[peft] ; extra == 'all' + requires_python: '>=3.10' +- pypi: https://files.pythonhosted.org/packages/ce/ff/3b59672c47c6284e8005b42e84ceba13864aa0f39f067c973d1af02f5d91/InquirerPy-0.3.4-py3-none-any.whl + name: inquirerpy + version: 0.3.4 + sha256: c65fdfbac1fa00e3ee4fb10679f4d3ed7a012abf4833910e63c295827fe2a7d4 + requires_dist: + - sphinx>=4.1.2,<5.0.0 ; extra == 'docs' + - furo>=2021.8.17b43,<2022.0.0 ; extra == 'docs' + - myst-parser>=0.15.1,<0.16.0 ; extra == 'docs' + - pfzy>=0.3.1,<0.4.0 + - prompt-toolkit>=3.0.1,<4.0.0 + - sphinx-autobuild>=2021.3.14,<2022.0.0 ; extra == 'docs' + - sphinx-copybutton>=0.4.0,<0.5.0 ; extra == 'docs' + requires_python: '>=3.7,<4.0' - pypi: https://files.pythonhosted.org/packages/d3/35/db860aa3a0780660324a506ad4b3d322ddc6ecbba4b9340aed0942cbf21c/hf_xet-1.5.2-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl name: hf-xet version: 1.5.2 @@ -50292,11 +53088,76 @@ packages: requires_dist: - pytest ; extra == 'tests' requires_python: '>=3.8' +- pypi: https://files.pythonhosted.org/packages/d5/ca/6a2cc3a73170d10b5af1f1613baa2ed1f8f46f62dd0bfab2bffd2c2fe260/pyyaml_include-1.4.1-py3-none-any.whl + name: pyyaml-include + version: 1.4.1 + sha256: 323c7f3a19c82fbc4d73abbaab7ef4f793e146a13383866831631b26ccc7fb00 + requires_dist: + - pyyaml>=6.0,<7.0 + - toml ; python_full_version < '3.12' and extra == 'toml' + requires_python: '>=3.6' +- pypi: https://files.pythonhosted.org/packages/d6/d0/d56c859b8222116f5d68459199f48359e0bf121b6f65a69bf329b3602ba0/yarl-1.24.5-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl + name: yarl + version: 1.24.5 + sha256: f08c7513ecef5aad65687bfdf6bc601ae9fccd04a42904501f8f7141abad9eb9 + requires_dist: + - idna>=2.0 + - multidict>=4.0 + - propcache>=0.2.1 + requires_python: '>=3.10' +- pypi: https://files.pythonhosted.org/packages/d6/d8/f87ea6f42456254b48915970ed98e993110521e9263472840174d32c880d/hf_transfer-0.1.9-cp38-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl + name: hf-transfer + version: 0.1.9 + sha256: cdca9bfb89e6f8f281890cc61a8aff2d3cecaff7e1a4d275574d96ca70098557 + requires_python: '>=3.7' +- pypi: https://files.pythonhosted.org/packages/da/35/f2287558c17e29fafc8ef3daf819bb9834061cfa43bff8014f7df7f63bdc/anyio-4.14.2-py3-none-any.whl + name: anyio + version: 4.14.2 + sha256: 9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494 + requires_dist: + - exceptiongroup>=1.0.2 ; python_full_version < '3.11' + - idna>=2.8 + - typing-extensions>=4.5 ; python_full_version < '3.13' + - trio>=0.32.0 ; extra == 'trio' + requires_python: '>=3.10' +- pypi: https://files.pythonhosted.org/packages/dc/61/e24b560ab2e2eaeb3c839129175fb330dfcfc29e5203196e5541a4c44682/nvidia_cublas_cu12-12.8.4.1-py3-none-manylinux_2_27_x86_64.whl + name: nvidia-cublas-cu12 + version: 12.8.4.1 + sha256: 8ac4e771d5a348c551b2a426eda6193c19aa630236b418086020df5ba9667142 + requires_python: '>=3' +- pypi: https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl + name: typing-inspection + version: 0.4.2 + sha256: 4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7 + requires_dist: + - typing-extensions>=4.12.0 + requires_python: '>=3.9' +- pypi: https://files.pythonhosted.org/packages/dd/5c/c139a7876099916879609372bfa513b7f1257f7f1a908b0bdc1c2328241b/opencv_python_headless-4.11.0.86-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl + name: opencv-python-headless + version: 4.11.0.86 + sha256: 0e0a27c19dd1f40ddff94976cfe43066fbbe9dfbb2ec1907d66c19caef42a57b + requires_dist: + - numpy>=1.13.3 ; python_full_version < '3.7' + - numpy>=1.21.0 ; python_full_version < '3.10' and platform_machine == 'arm64' and sys_platform == 'darwin' + - numpy>=1.21.2 ; python_full_version >= '3.10' + - numpy>=1.21.4 ; python_full_version >= '3.10' and sys_platform == 'darwin' + - numpy>=1.23.5 ; python_full_version >= '3.11' + - numpy>=1.26.0 ; python_full_version >= '3.12' + - numpy>=1.19.3 ; python_full_version >= '3.6' and platform_machine == 'aarch64' and sys_platform == 'linux' + - numpy>=1.17.0 ; python_full_version >= '3.7' + - numpy>=1.17.3 ; python_full_version >= '3.8' + - numpy>=1.19.3 ; python_full_version >= '3.9' + requires_python: '>=3.6' - pypi: https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl name: packaging version: '26.2' sha256: 5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e requires_python: '>=3.8' +- pypi: https://files.pythonhosted.org/packages/e0/6c/323c40671c6f1b3e02bb4a7404fbe2bf653190a56e63cf4b6a4f06e876bc/cmake-4.1.3-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl + name: cmake + version: 4.1.3 + sha256: 81f11b72bc59cbe547d9f283487ef0519bf68176edffcdfa1a4dc5a52f292369 + requires_python: '>=3.8' - pypi: https://files.pythonhosted.org/packages/e5/22/4222d7ddf3da30f363edaa98e329c2bce6c65497c9cb2810931c8b2c0fbc/fsspec-2026.6.0-py3-none-any.whl name: fsspec version: 2026.6.0 @@ -50405,6 +53266,303 @@ packages: - zstandard ; python_full_version < '3.14' and extra == 'test-full' - tqdm ; extra == 'tqdm' requires_python: '>=3.10' +- pypi: https://files.pythonhosted.org/packages/e6/ab/fb21f4c939bb440104cc2b396d3be1d9b7a9fd3c6c2a53d98c45b3d7c954/fsspec-2026.2.0-py3-none-any.whl + name: fsspec + version: 2026.2.0 + sha256: 98de475b5cb3bd66bedd5c4679e87b4fdfe1a3bf4d707b151b3c07e58c9a2437 + requires_dist: + - adlfs ; extra == 'abfs' + - adlfs ; extra == 'adl' + - pyarrow>=1 ; extra == 'arrow' + - dask ; extra == 'dask' + - distributed ; extra == 'dask' + - pre-commit ; extra == 'dev' + - ruff>=0.5 ; extra == 'dev' + - numpydoc ; extra == 'doc' + - sphinx ; extra == 'doc' + - sphinx-design ; extra == 'doc' + - sphinx-rtd-theme ; extra == 'doc' + - yarl ; extra == 'doc' + - dropbox ; extra == 'dropbox' + - dropboxdrivefs ; extra == 'dropbox' + - requests ; extra == 'dropbox' + - adlfs ; extra == 'full' + - aiohttp!=4.0.0a0,!=4.0.0a1 ; extra == 'full' + - dask ; extra == 'full' + - distributed ; extra == 'full' + - dropbox ; extra == 'full' + - dropboxdrivefs ; extra == 'full' + - fusepy ; extra == 'full' + - gcsfs>2024.2.0 ; extra == 'full' + - libarchive-c ; extra == 'full' + - ocifs ; extra == 'full' + - panel ; extra == 'full' + - paramiko ; extra == 'full' + - pyarrow>=1 ; extra == 'full' + - pygit2 ; extra == 'full' + - requests ; extra == 'full' + - s3fs>2024.2.0 ; extra == 'full' + - smbprotocol ; extra == 'full' + - tqdm ; extra == 'full' + - fusepy ; extra == 'fuse' + - gcsfs>2024.2.0 ; extra == 'gcs' + - pygit2 ; extra == 'git' + - requests ; extra == 'github' + - gcsfs ; extra == 'gs' + - panel ; extra == 'gui' + - pyarrow>=1 ; extra == 'hdfs' + - aiohttp!=4.0.0a0,!=4.0.0a1 ; extra == 'http' + - libarchive-c ; extra == 'libarchive' + - ocifs ; extra == 'oci' + - s3fs>2024.2.0 ; extra == 's3' + - paramiko ; extra == 'sftp' + - smbprotocol ; extra == 'smb' + - paramiko ; extra == 'ssh' + - aiohttp!=4.0.0a0,!=4.0.0a1 ; extra == 'test' + - numpy ; extra == 'test' + - pytest ; extra == 'test' + - pytest-asyncio!=0.22.0 ; extra == 'test' + - pytest-benchmark ; extra == 'test' + - pytest-cov ; extra == 'test' + - pytest-mock ; extra == 'test' + - pytest-recording ; extra == 'test' + - pytest-rerunfailures ; extra == 'test' + - requests ; extra == 'test' + - aiobotocore>=2.5.4,<3.0.0 ; extra == 'test-downstream' + - dask[dataframe,test] ; extra == 'test-downstream' + - moto[server]>4,<5 ; extra == 'test-downstream' + - pytest-timeout ; extra == 'test-downstream' + - xarray ; extra == 'test-downstream' + - adlfs ; extra == 'test-full' + - aiohttp!=4.0.0a0,!=4.0.0a1 ; extra == 'test-full' + - backports-zstd ; python_full_version < '3.14' and extra == 'test-full' + - cloudpickle ; extra == 'test-full' + - dask ; extra == 'test-full' + - distributed ; extra == 'test-full' + - dropbox ; extra == 'test-full' + - dropboxdrivefs ; extra == 'test-full' + - fastparquet ; extra == 'test-full' + - fusepy ; extra == 'test-full' + - gcsfs ; extra == 'test-full' + - jinja2 ; extra == 'test-full' + - kerchunk ; extra == 'test-full' + - libarchive-c ; extra == 'test-full' + - lz4 ; extra == 'test-full' + - notebook ; extra == 'test-full' + - numpy ; extra == 'test-full' + - ocifs ; extra == 'test-full' + - pandas<3.0.0 ; extra == 'test-full' + - panel ; extra == 'test-full' + - paramiko ; extra == 'test-full' + - pyarrow ; extra == 'test-full' + - pyarrow>=1 ; extra == 'test-full' + - pyftpdlib ; extra == 'test-full' + - pygit2 ; extra == 'test-full' + - pytest ; extra == 'test-full' + - pytest-asyncio!=0.22.0 ; extra == 'test-full' + - pytest-benchmark ; extra == 'test-full' + - pytest-cov ; extra == 'test-full' + - pytest-mock ; extra == 'test-full' + - pytest-recording ; extra == 'test-full' + - pytest-rerunfailures ; extra == 'test-full' + - python-snappy ; extra == 'test-full' + - requests ; extra == 'test-full' + - smbprotocol ; extra == 'test-full' + - tqdm ; extra == 'test-full' + - urllib3 ; extra == 'test-full' + - zarr ; extra == 'test-full' + - zstandard ; python_full_version < '3.14' and extra == 'test-full' + - tqdm ; extra == 'tqdm' + requires_python: '>=3.10' +- pypi: https://files.pythonhosted.org/packages/e9/73/fda6a25f3beeb5e49d74330b44092b9e5a547395ccd478d1103ddcbff1fc/gymnasium-1.3.0-py3-none-any.whl + name: gymnasium + version: 1.3.0 + sha256: 6b8c159a8540dcbcb221722d7efda24d78ebbcbc3bd2ea1c2611aa2a34471fc2 + requires_dist: + - numpy>=1.21.0 + - cloudpickle>=1.2.0 + - typing-extensions>=4.3.0 + - farama-notifications>=0.0.1 + - ale-py>=0.9 ; extra == 'atari' + - box2d==2.3.10 ; extra == 'box2d' + - pygame-ce>=2.1.3 ; extra == 'box2d' + - swig==4.* ; extra == 'box2d' + - pygame-ce>=2.1.3 ; extra == 'classic-control' + - pygame-ce>=2.1.3 ; extra == 'classic-control' + - mujoco>=2.1.5 ; extra == 'mujoco' + - imageio>=2.14.1 ; extra == 'mujoco' + - packaging>=23.0 ; extra == 'mujoco' + - pygame-ce>=2.1.3 ; extra == 'toy-text' + - pygame-ce>=2.1.3 ; extra == 'toy-text' + - jax>=0.4.16 ; extra == 'jax' + - jaxlib>=0.4.16 ; extra == 'jax' + - flax>=0.5.0 ; extra == 'jax' + - array-api-compat>=1.11.0 ; extra == 'jax' + - numpy>=2.1 ; extra == 'jax' + - torch>=1.13.0 ; extra == 'torch' + - array-api-compat>=1.11.0 ; extra == 'torch' + - numpy>=2.1 ; extra == 'torch' + - array-api-compat>=1.11.0 ; extra == 'array-api' + - numpy>=2.1 ; extra == 'array-api' + - packaging>=23.0 ; extra == 'array-api' + - moviepy>=1.0.0 ; extra == 'other' + - matplotlib>=3.0 ; extra == 'other' + - opencv-python>=3.0 ; extra == 'other' + - seaborn>=0.13 ; extra == 'other' + - ale-py>=0.9 ; extra == 'all' + - box2d-py==2.3.5 ; extra == 'all' + - pygame-ce>=2.1.3 ; extra == 'all' + - swig==4.* ; extra == 'all' + - pygame-ce>=2.1.3 ; extra == 'all' + - mujoco>=2.1.5 ; extra == 'all' + - imageio>=2.14.1 ; extra == 'all' + - packaging>=23.0 ; extra == 'all' + - pygame-ce>=2.1.3 ; extra == 'all' + - jax>=0.4.16 ; extra == 'all' + - jaxlib>=0.4.16 ; extra == 'all' + - flax>=0.5.0 ; extra == 'all' + - array-api-compat>=1.11.0 ; extra == 'all' + - numpy>=2.1 ; extra == 'all' + - torch>=1.13.0 ; extra == 'all' + - array-api-compat>=1.11.0 ; extra == 'all' + - numpy>=2.1 ; extra == 'all' + - array-api-compat>=1.11.0 ; extra == 'all' + - numpy>=2.1 ; extra == 'all' + - opencv-python>=3.0 ; extra == 'all' + - matplotlib>=3.0 ; extra == 'all' + - moviepy>=1.0.0 ; extra == 'all' + - pytest>=7.1.3 ; extra == 'testing' + - scipy>=1.7.3 ; extra == 'testing' + - dill>=0.3.7 ; extra == 'testing' + - array-api-extra>=0.7.0 ; extra == 'testing' + requires_python: '>=3.10' +- pypi: https://files.pythonhosted.org/packages/ec/0c/9d8752098bc442f0726e64aa6135940b3a96809915d1aa4206c1bb97881d/gitpython-3.1.58-py3-none-any.whl + name: gitpython + version: 3.1.58 + sha256: d331e722577f0fd7fc1f857419b3ecc07af66282b933d2a4d95f84a042fdd50f + requires_dist: + - gitdb>=4.0.1,<5 + - typing-extensions>=3.10.0.2 ; python_full_version < '3.10' + - coverage[toml] ; extra == 'test' + - basedpyright==1.39.9 ; python_full_version >= '3.9' and sys_platform != 'cygwin' and extra == 'test' + - ddt>=1.1.1,!=1.4.3 ; extra == 'test' + - mock ; python_full_version < '3.8' and extra == 'test' + - mypy==1.18.2 ; python_full_version >= '3.9' and extra == 'test' + - pre-commit ; extra == 'test' + - pytest>=7.3.1 ; extra == 'test' + - pytest-cov ; extra == 'test' + - pytest-instafail ; extra == 'test' + - pytest-mock ; extra == 'test' + - pytest-sugar ; extra == 'test' + - typing-extensions ; python_full_version < '3.11' and extra == 'test' + - sphinx>=7.4.7,<8 ; extra == 'doc' + - sphinx-rtd-theme ; extra == 'doc' + - sphinx-autodoc-typehints ; extra == 'doc' + requires_python: '>=3.7' +- pypi: https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl + name: python-dateutil + version: 2.9.0.post0 + sha256: a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427 + requires_dist: + - six>=1.5 + requires_python: '>=2.7,!=3.0.*,!=3.1.*,!=3.2.*' +- pypi: https://files.pythonhosted.org/packages/f3/8d/5e5be3ced1d12966fefb5c4ea3b2a5b480afcea36406559442c6e31d4a48/multidict-6.7.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl + name: multidict + version: 6.7.1 + sha256: bfde23ef6ed9db7eaee6c37dcec08524cb43903c60b285b172b6c094711b3961 + requires_dist: + - typing-extensions>=4.1.0 ; python_full_version < '3.11' + requires_python: '>=3.9' +- pypi: https://files.pythonhosted.org/packages/f5/86/0b9c8f56398b4fc85f8e99279907c258413a297e5603f8f2537fe5806e51/rerun_sdk-0.26.2-cp39-abi3-manylinux_2_28_x86_64.whl + name: rerun-sdk + version: 0.26.2 + sha256: a6f97b60aaa7d4e8c6124a3f6b97ce9dbd09520050955f0e0bdacb72b0eb106a + requires_dist: + - attrs>=23.1.0 + - numpy>=2 + - pillow>=8.0.0 + - pyarrow>=18.0.0 + - typing-extensions>=4.5 + - pytest==8.4.1 ; extra == 'tests' + - rerun-notebook==0.26.2 ; extra == 'notebook' + - datafusion==49.0.0 ; extra == 'datafusion' + - rerun-sdk[notebook] ; extra == 'all' + - rerun-sdk[datafusion] ; extra == 'all' + requires_python: '>=3.9' +- pypi: https://files.pythonhosted.org/packages/f6/74/86a07f1d0f42998ca31312f998bd3b9a7eff7f52378f4f270c8679c77fb9/nvidia_nvjitlink_cu12-12.8.93-py3-none-manylinux2010_x86_64.manylinux_2_12_x86_64.whl + name: nvidia-nvjitlink-cu12 + version: 12.8.93 + sha256: 81ff63371a7ebd6e6451970684f916be2eab07321b73c9d244dc2b4da7f73b88 + requires_python: '>=3' +- pypi: https://files.pythonhosted.org/packages/f8/02/2adcaa145158bf1a8295d83591d22e4103dbfd821bcaf6f3f53151ca4ffa/nvidia_cuda_cupti_cu12-12.8.90-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl + name: nvidia-cuda-cupti-cu12 + version: 12.8.90 + sha256: ea0cb07ebda26bb9b29ba82cda34849e73c166c18162d3913575b0c9db9a6182 + requires_python: '>=3' +- pypi: https://files.pythonhosted.org/packages/f8/62/d9ba6323b9202dd2fe166beab8a86d29465c41a0288cbe229fac60c1ab8d/jsonlines-4.0.0-py3-none-any.whl + name: jsonlines + version: 4.0.0 + sha256: 185b334ff2ca5a91362993f42e83588a360cf95ce4b71a73548502bda52a7c55 + requires_dist: + - attrs>=19.2.0 + requires_python: '>=3.8' +- pypi: https://files.pythonhosted.org/packages/f9/1c/01bfd571a64e7f270e6bab5e33777debe0edc56759233ce84f27dec92d14/tqdm-4.70.0-py3-none-any.whl + name: tqdm + version: 4.70.0 + sha256: 7f585706bfddbdebf89daac705b2dfcc16890130727d3197ca62c732b4310953 + requires_dist: + - colorama ; sys_platform == 'win32' + - requests ; extra == 'discord' + - envwrap ; extra == 'discord' + - slack-sdk ; extra == 'slack' + - envwrap ; extra == 'slack' + - requests ; extra == 'telegram' + - envwrap ; extra == 'telegram' + - ipywidgets>=6 ; extra == 'notebook' + requires_python: '>=3.8' +- pypi: https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl + name: aiosignal + version: 1.4.0 + sha256: 053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e + requires_dist: + - frozenlist>=1.1.0 + - typing-extensions>=4.2 ; python_full_version < '3.13' + requires_python: '>=3.9' +- pypi: https://files.pythonhosted.org/packages/fb/aa/6584b56dc84ebe9cf93226a5cde4d99080c8e90ab40f0c27bda7a0f29aa1/nvidia_curand_cu12-10.3.9.90-py3-none-manylinux_2_27_x86_64.whl + name: nvidia-curand-cu12 + version: 10.3.9.90 + sha256: b32331d4f4df5d6eefa0554c565b626c7216f87a06a4f56fab27c3b68a830ec9 + requires_python: '>=3' +- pypi: https://files.pythonhosted.org/packages/fb/e2/79c688af8b210d232694e31e59da9f6ec747bae31c3f5946e4e9b98860d5/click-8.4.2-py3-none-any.whl + name: click + version: 8.4.2 + sha256: e6f9f66136c816745b9d65817da91d61d957fb16e02e4dcd0552553c5a197b76 + requires_dist: + - colorama ; sys_platform == 'win32' + requires_python: '>=3.10' +- pypi: https://files.pythonhosted.org/packages/fc/b4/d088047afe39827556df21118cac9ffd20cc3f968c99a7681494d1eb333c/cuda_pathfinder-1.6.0-py3-none-any.whl + name: cuda-pathfinder + version: 1.6.0 + sha256: 1503af579d8379c24bdd65528379bc57039b0455be9f5f9686cf8e473a1fce51 + requires_python: '>=3.10' +- pypi: https://files.pythonhosted.org/packages/fc/b8/ff33610932e0ee81ae7f1269c890f697d56ff74b9f5b2ee5d9b7fa2c5355/python_xlib-0.33-py2.py3-none-any.whl + name: python-xlib + version: '0.33' + sha256: c3534038d42e0df2f1392a1b30a15a4ff5fdc2b86cfa94f072bf11b10a164398 + requires_dist: + - six>=1.10.0 +- pypi: https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl + name: pydantic + version: 2.13.4 + sha256: 45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba + requires_dist: + - annotated-types>=0.6.0 + - pydantic-core==2.46.4 + - typing-extensions>=4.14.1 + - typing-inspection>=0.4.2 + - email-validator>=2.0.0 ; extra == 'email' + - tzdata ; python_full_version >= '3.9' and sys_platform == 'win32' and extra == 'timezone' + requires_python: '>=3.9' - pypi: https://files.pythonhosted.org/packages/fe/21/99a0cdaf54eb35e77623c41b5a2c9472ee4404bba687052791fe2aba6773/tqdm-4.69.0-py3-none-any.whl name: tqdm version: 4.69.0 diff --git a/pixi.toml b/pixi.toml index ae48478..ebee3eb 100644 --- a/pixi.toml +++ b/pixi.toml @@ -162,6 +162,25 @@ arm-calibrate = "ros2 run mote_arm arm_calibrate" arm-offsets = "ros2 run mote_arm arm_offsets" # Show/apply the arm servos' position-loop gains from robot.yaml (EEPROM). arm-gains = "ros2 run mote_arm arm_gains" +# Virtual-leader teleop (mote_arm/TELEOP.md). No leader arm: the keyboard moves a +# leader pose, `arm_mirror` rate-limits and clamps it onto arm_controller. Bring +# the control stack up with the mirror beside it: `pixi run arm mirror:=true`. +arm-teleop = "ros2 run mote_arm virtual_leader" +arm-mirror = "ros2 run mote_arm arm_mirror" +# The arm control stack's interface (trajectory topic + switch_controller) with +# no hardware behind it — teleop, recording and replay all run against this on a +# workstation. `-- --camera` adds a synthetic camera so episodes have frames. +arm-mock = "ros2 run mote_arm mock_arm" +# Record teleop episodes (observations + actions) into $MOTE_HOME/episodes, and +# replay one on the arm at reduced speed. Export them with `arm-export` below. +arm-record = "ros2 run mote_arm episode_record" +arm-replay = "ros2 run mote_arm episode_replay" +# The whole teleop -> record -> export -> replay loop against the mock arm; the +# pre-bench gate for anything that touches the arm's teleop path. +arm-teleop-test = "bash mote_arm/test/teleop_loop/run_teleop_loop.sh" +# The same loop on real hardware, guided: prompts for the safety observations a +# script cannot make, and writes a report (mote_arm/BENCH.md). +arm-bench-teleop = "bash mote_arm/tools/bench_teleop.sh" # Robot-side inference diagnostics (torch-free — run in the default/robot env): # probe the inference machine's health/version, or benchmark round-trip latency. inference-health = "python -u mote_perception/tools/inference_health.py" @@ -445,6 +464,29 @@ test-fleet = "pytest mote_fleet/test -q" # M2's teleop path against the real bridge; needs the dev env's WebSocket client. test-foxglove = "pytest mote_bringup/test/test_foxglove_teleop.py -q" +# Off-board LeRobot env: converts on-robot episode captures into a LeRobotDataset +# and inspects it with LeRobot's own tooling. Its own env, no-default-feature and +# linux-64 only, for the same reason `inference` has one — lerobot brings torch, +# ffmpeg and the HuggingFace stack, none of which belongs on the aarch64 Pi that +# does the recording. The robot writes a plain capture directory; this converts +# it (see mote_arm/tools/lerobot_export.py). +[feature.lerobot] +platforms = ["linux-64"] + +[feature.lerobot.dependencies] +python = "3.12.*" +numpy = ">=1.26,<3" +pillow = ">=10,<13" +# The video path LeRobot encodes MP4 shards with. +ffmpeg = ">=6" + +[feature.lerobot.pypi-dependencies] +# v3.0 datasets (aggregated parquet/mp4 shards) land in lerobot >= 0.4. +lerobot = ">=0.4" + +[feature.lerobot.tasks] +arm-export = "python -u mote_arm/tools/lerobot_export.py" + [feature.lint.dependencies] pre-commit = ">=4,<5" @@ -472,6 +514,8 @@ fleet = { features = ["fleet"], no-default-feature = true } sim = { features = ["sim"] } inference = { features = ["inference"], no-default-feature = true } inference-rocm = { features = ["inference-rocm"], no-default-feature = true } +# Off-board episode export + LeRobot's dataset viewer. Own solve, no ROS. +lerobot = { features = ["lerobot"], no-default-feature = true } # ^ GPU sibling of `inference`; own solve (no solve-group) so the ROCm torch wheel # can never perturb any other env. # Minimal env (no ROS) so 'pixi run lint' is fast to solve and install From 0fa5c1bc6f6cb0c83d50c485bb6d52a01420e33d Mon Sep 17 00:00:00 2001 From: Michael Johnson Date: Wed, 2 Sep 2026 15:57:41 +0100 Subject: [PATCH 02/22] Review: reap the loop test's nodes by session, and stop check_capture crashing Three findings from the PR review, all confirmed by measurement before fixing. run_teleop_loop.sh leaked two nodes per run. `ros2 run` is a wrapper that Popens the real executable and handles no SIGTERM, so `kill $!` killed the wrapper and handed the node to init -- the class of straggler `pixi run sweep` exists to find, in a script meant to be run repeatedly as a pre-bench gate. Measured on this branch: arm_mirror and mock_arm both survived a run that exited 0. Every job is now setsid-ed into its own session and torn down by process group and then by session id, which is the sim smoke test's scoping and the shell equivalent of sweep_orphans.spawn_reapable/reap_group. Same run afterwards: 0 surviving nodes, loop still passes. check_capture.py crashed on a frame whose image file is missing. It computed exactly that diagnostic, then re-stat()ed the same absent file to compare frame sizes -- so the report it had just built was never printed, and neither were any problems for later episodes. There is now one reading of "this frame has an image", used by both checks. Against a capture whose last frame names a file that never landed: FileNotFoundError before, "1/30 frames have no image" after. mock_arm.py hand-rolled its rclpy teardown while every other CLI here goes through cli.py, and carried a `see cli.spin_background` comment pointing at a function it did not call. It now spins on a worker thread and exits through cli.shutdown. To be clear about what this is: it is compliance with the rule in CLAUDE.md and the removal of a misleading comment, not a crash fix -- the old form was measured too and also exited 0 with no abort 3/3, because a main-thread spin() has already returned by the time the finally block destroys the node. The new form is 0/3 aborts as well. 1144 tests pass, lint clean. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_017hfWyNtkVh16hyvCK8K9cd --- mote_arm/mote_arm/mock_arm.py | 21 ++++----- mote_arm/test/teleop_loop/check_capture.py | 17 +++++--- mote_arm/test/teleop_loop/run_teleop_loop.sh | 46 +++++++++++++++----- 3 files changed, 55 insertions(+), 29 deletions(-) diff --git a/mote_arm/mote_arm/mock_arm.py b/mote_arm/mote_arm/mock_arm.py index 6f01a64..f54af40 100644 --- a/mote_arm/mote_arm/mock_arm.py +++ b/mote_arm/mote_arm/mock_arm.py @@ -34,7 +34,6 @@ import rclpy from controller_manager_msgs.srv import SwitchController -from rclpy.executors import ExternalShutdownException from rclpy.node import Node from sensor_msgs.msg import CompressedImage, JointState from trajectory_msgs.msg import JointTrajectory @@ -208,20 +207,18 @@ def main() -> None: args = cli.parse(parser) rclpy.init() - node = None + node = MockArm(args) + # Spun on a worker thread and joined from here, rather than spun in the main + # thread, so the teardown is the one in cli.py: shut the context down, join + # the spinner, and only then destroy the node. The main thread has nothing + # else to do — waiting on the spinner is what gives SIGINT somewhere to land. + spinner = cli.spin_background(node) try: - node = MockArm(args) - rclpy.spin(node) - except (KeyboardInterrupt, ExternalShutdownException): + spinner.join() + except KeyboardInterrupt: pass - except Exception: # noqa: BLE001 - the context is gone, see cli.spin_background - if rclpy.ok(): - raise finally: - if node is not None: - node.destroy_node() - if rclpy.ok(): - rclpy.shutdown() + cli.shutdown(node, spinner) if __name__ == "__main__": diff --git a/mote_arm/test/teleop_loop/check_capture.py b/mote_arm/test/teleop_loop/check_capture.py index a8a50a5..dbf8cdb 100755 --- a/mote_arm/test/teleop_loop/check_capture.py +++ b/mote_arm/test/teleop_loop/check_capture.py @@ -54,15 +54,18 @@ def main() -> int: ) if spec.camera is not None: - missing = [ - f for f in frames if not f.image or not (path / f.image).exists() - ] - if missing: + # One reading of "this frame has an image", used by both checks: the + # second used to re-test only that a filename was recorded, so a + # frame naming a file that is not on disk was reported as missing + # and then stat()ed anyway, raising through the whole report. + present = [f for f in frames if f.image and (path / f.image).exists()] + if len(present) < len(frames): problems.append( - f"{name}: {len(missing)}/{len(frames)} frames have no image" + f"{name}: {len(frames) - len(present)}/{len(frames)} " + "frames have no image" ) - sizes = {(path / f.image).stat().st_size for f in frames if f.image} - if len(sizes) < 2: + sizes = {(path / f.image).stat().st_size for f in present} + if present and len(sizes) < 2: problems.append(f"{name}: every camera frame is byte-identical") gridded = resample(frames, spec.fps) diff --git a/mote_arm/test/teleop_loop/run_teleop_loop.sh b/mote_arm/test/teleop_loop/run_teleop_loop.sh index d97de58..2e9912c 100755 --- a/mote_arm/test/teleop_loop/run_teleop_loop.sh +++ b/mote_arm/test/teleop_loop/run_teleop_loop.sh @@ -23,12 +23,33 @@ mkdir -p "$LOGS" export ROS_DOMAIN_ID=$((RANDOM % 40 + 60)) export ROS_AUTOMATIC_DISCOVERY_RANGE=LOCALHOST -PIDS=() +# `ros2 run` is a wrapper that Popens the real executable and handles no SIGTERM, +# so killing its pid hands the node to init and leaks it — the exact class of +# straggler `pixi run sweep` exists to find (CLAUDE.md, "Stray ROS processes"). +# Every job is therefore setsid-ed into a session of its own, and torn down by +# process group and then by session id: the shell equivalent of +# sweep_orphans.spawn_reapable/reap_group, and the same scoping the sim smoke +# test uses. +NAMES=() +declare -A PID_OF +declare -A SID_OF +OUR_SID="$(ps -o sid= -p $$ | tr -d ' ')" + +reap() { + local name="$1" pid="${PID_OF[$1]:-}" sid="${SID_OF[$1]:-}" + [ -n "$pid" ] || return 0 + kill -- -"$pid" 2>/dev/null || kill "$pid" 2>/dev/null || true + wait "$pid" 2>/dev/null || true + # Backstop for anything that left the group but not the session. + [ -n "$sid" ] && pkill -9 -s "$sid" 2>/dev/null + unset "PID_OF[$name]" + true +} + cleanup() { - for pid in "${PIDS[@]:-}"; do - kill "$pid" 2>/dev/null || true + for name in "${NAMES[@]:-}"; do + [ -n "$name" ] && reap "$name" done - wait 2>/dev/null || true } trap cleanup EXIT @@ -39,19 +60,24 @@ fail() { exit 1 } -declare -A PID_OF background() { local name="$1" shift - "$@" >"$LOGS/$name.log" 2>&1 & - PID_OF[$name]=$! - PIDS+=("$!") + setsid "$@" >"$LOGS/$name.log" 2>&1 & + local pid=$! + NAMES+=("$name") + PID_OF[$name]=$pid + local sid + sid="$(ps -o sid= -p "$pid" 2>/dev/null | tr -d ' ')" + # If setsid did not detach it, the job shares OUR session and killing that + # session would take this script with it — drop the scope instead. + [ "$sid" = "$OUR_SID" ] && sid="" + SID_OF[$name]="$sid" } stop() { for name in "$@"; do - kill "${PID_OF[$name]}" 2>/dev/null || true - wait "${PID_OF[$name]}" 2>/dev/null || true + reap "$name" done } From 68e504032c8670940af1c45bc8a012d1ff063ab0 Mon Sep 17 00:00:00 2001 From: Michael Johnson Date: Wed, 2 Sep 2026 16:16:43 +0100 Subject: [PATCH 03/22] bench_teleop: name the terminal, ask one thing at a time, check what it can check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Found by an operator following the script on mote-01, and every one of these is the script's fault rather than theirs. It printed five instructions "in terminal B", then fired five y/N prompts that only terminal C can accept — so by the time you had switched windows, done the thing and come back, nothing said which of the five was being asked. Worse, the answers collide with live teleop keys: in the leader, 'y' drives joint 6 and 'z' clears the panic latch, so an answer aimed at the wrong window moves the arm instead of being read. Each action is now printed immediately before its own question, every prompt is marked [answer HERE], and the collision is stated rather than left to be discovered. Terminals are named by what runs in them. "Terminal B" means nothing to someone who did not lay the windows out in that order; "the TELEOP terminal" is self-describing. The header also had the count wrong — recording with the camera needs `pixi run launch` and `pixi run arm-mirror` in separate windows, so it is four terminals, not three. Two preconditions were missing, both of which let the operator get a long way in before anything looked wrong: - No check that the virtual leader is running. Every check in step 2 asks you to drive the arm from a window that, with only launch + arm-mirror + this script up, does not exist — and the script announced PASS on its preconditions first. - Step 6 *asked* whether the leader had been stopped and believed the answer. An operator who is wrong gets a replay that loses to the mirror and reports a stall, which reads exactly like the arm failing and is the one failure here that is not about the arm. It now watches for the node to exit instead. Verified against the mock (mock_arm --camera + arm_mirror + virtual_leader --demo): preconditions pass including the new leader check, the interleaved checks read one at a time, an episode records, and the replay runs 200/200 setpoints at 0.010 rad of lag once the leader has actually gone. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_017hfWyNtkVh16hyvCK8K9cd --- mote_arm/tools/bench_teleop.sh | 103 +++++++++++++++++++++++---------- 1 file changed, 74 insertions(+), 29 deletions(-) diff --git a/mote_arm/tools/bench_teleop.sh b/mote_arm/tools/bench_teleop.sh index 535348b..80e20c7 100755 --- a/mote_arm/tools/bench_teleop.sh +++ b/mote_arm/tools/bench_teleop.sh @@ -6,10 +6,14 @@ # same loop headless against the mock follower. Run that first — this script # assumes the software already works and is here to check the *arm* does. # -# Three terminals: -# A: pixi run arm mirror:=true driver + mirror -# B: pixi run arm-teleop the virtual leader (you drive this) -# C: bash mote_arm/tools/bench_teleop.sh <- this script +# Four terminals. The first two are the robot, the third is what you drive, and +# the fourth is this script: +# +# 1. pixi run launch base: controllers (arm included) + camera +# or, with no camera needed: pixi run arm mirror:=true, which folds in 2 +# 2. pixi run arm-mirror leader pose -> arm_controller +# 3. pixi run arm-teleop the virtual leader — YOU DRIVE THIS ONE +# 4. pixi run arm-bench-teleop <- this script: asks, records, replays # # It writes a report you can paste into the task; nothing is recorded as passing # that you did not say you saw. @@ -23,10 +27,15 @@ HERE="$(cd "$(dirname "$0")" && pwd)" note() { printf '%s\n' "$*" | tee -a "$REPORT"; } rule() { printf '\n== %s ==\n' "$*" | tee -a "$REPORT"; } +# Every answer is typed in THIS terminal, never in the teleop one: there, 'y' +# drives joint 6 and 'z' clears the panic latch, so an answer aimed at the wrong +# window moves the arm instead of being read. Hence the marker on every prompt. +HERE_MARK="[answer HERE]" + ask() { # ask "" -> records observed / NOT OBSERVED local prompt="$1" reply - read -r -p " $prompt [y/N] " reply + read -r -p " $HERE_MARK $prompt [y/N] " reply if [[ "$reply" =~ ^[Yy] ]]; then note " PASS $prompt" else @@ -35,6 +44,13 @@ ask() { fi } +check() { + # check "" "" + echo + echo " -> in the TELEOP terminal: $1" + ask "$2" +} + FAILURES=0 mkdir -p "$CAPTURE" : >"$REPORT" @@ -47,44 +63,60 @@ cat <<'EOF' Before starting, confirm at the arm: * it is powered, physically supported, and free to move through its band * `pixi run arm-gains show` reports kp=32 (droop, not stall — see README) - * terminal A is running `pixi run arm mirror:=true` - * terminal B is running `pixi run arm-teleop` + * the arm is up: `pixi run launch` (with the camera) or `pixi run arm` + * `pixi run arm-mirror` is running, unless the arm terminal has mirror:=true + * the TELEOP terminal is running `pixi run arm-teleop` — the one you drive + +This is the last terminal: it asks the questions and records the answers. EOF -read -r -p " ready? [y/N] " ready +read -r -p " $HERE_MARK ready? [y/N] " ready [[ "$ready" =~ ^[Yy] ]] || { echo "aborted"; exit 1; } rule "1. the arm is reporting" if timeout 10 ros2 topic echo --once /joint_states >/dev/null 2>&1; then note " PASS /joint_states is publishing" else - note " FAIL no /joint_states — is terminal A running?" + note " FAIL no /joint_states — is the ARM terminal running?" exit 1 fi -if ros2 node list 2>/dev/null | grep -q arm_mirror; then +NODES="$(ros2 node list 2>/dev/null)" +if grep -q arm_mirror <<<"$NODES"; then note " PASS arm_mirror is up" else - note " FAIL arm_mirror is not running — start terminal A with mirror:=true" + note " FAIL arm_mirror is not running — run \`pixi run arm-mirror\`, or" + note " start the arm terminal with mirror:=true" + exit 1 +fi +if grep -q virtual_leader <<<"$NODES"; then + note " PASS the virtual leader is up" +else + note " FAIL no virtual_leader — every check below asks you to drive the arm" + note " from it. Open another terminal and run \`pixi run arm-teleop\`." exit 1 fi rule "2. teleop, and the three safety behaviours" cat <<'EOF' -In terminal B, with a hand ready to hit SPACE: +One at a time: do the action in the TELEOP terminal (`pixi run arm-teleop`), +then come back to THIS terminal and answer. Keep a hand on SPACE throughout. - a) hold one joint's key and watch the arm follow smoothly - b) keep holding past the joint's soft limit — it must stop at the limit - c) release the key mid-move — it must stop within a fraction of a second - d) press SPACE — the arm must go limp immediately (PANIC latches) - e) press z to clear, then drive again — it must follow from where it is +Do not answer in the teleop terminal — 'y' drives joint 6 there and 'z' clears +the panic latch, so an answer typed into the wrong window moves the arm. EOF -ask "(a) the arm followed the leader smoothly" -ask "(b) it stopped at the soft limit and went no further" -ask "(c) releasing the key halted it" -ask "(d) SPACE dropped torque and the arm went limp" -ask "(e) clearing the panic resumed following without a jump" +check "hold one joint's key and watch the arm move" \ + "(a) the arm followed the leader smoothly" +check "keep holding that same key past the joint's soft limit" \ + "(b) it stopped at the limit and went no further" +check "drive again, then release the key mid-move" \ + "(c) releasing the key halted it within a fraction of a second" +check "press SPACE" \ + "(d) PANIC dropped torque and the arm went limp" +check "press z to clear the latch, then drive again" \ + "(e) it resumed following from where it is, with no jump" rule "3. record an episode" -echo "Teleop a simple motion in terminal B while this records." +echo "Drive a simple motion in the TELEOP terminal while this records." +echo "The ENTER prompts below are read HERE, not there." ros2 run mote_arm episode_record --task "${TASK:-move the arm through a simple motion}" \ --dataset "$DATASET" --episodes 1 2>&1 | tee -a "$REPORT" @@ -110,14 +142,27 @@ EOF ask "the export verified, and the viewer showed the episode" rule "6. replay it on the arm" -echo "Stop terminal B (x) before replaying — two publishers would fight." -read -r -p " virtual leader stopped? [y/N] " stopped -if [[ "$stopped" =~ ^[Yy] ]]; then +echo "Stop the TELEOP terminal now (press x there): the mirror and the replay" +echo "would otherwise both command arm_controller and fight over the arm." +echo +# Watched rather than asked. An operator who says the leader is stopped when it +# is not gets a replay that loses to the mirror and reports a stall — which +# reads exactly like the arm failing, and is the one failure here that is not +# about the arm at all. +echo -n " waiting for the virtual leader to exit" +for _ in $(seq 60); do + ros2 node list 2>/dev/null | grep -q virtual_leader || break + echo -n "." + sleep 2 +done +echo +if ros2 node list 2>/dev/null | grep -q virtual_leader; then + note " SKIP replay: the virtual leader is still running after 2 minutes" + FAILURES=$((FAILURES + 1)) +else + note " the virtual leader has exited; replaying" ros2 run mote_arm episode_replay "$CAPTURE" --episode 0 --speed-scale 0.25 2>&1 | tee -a "$REPORT" ask "the arm retraced the recorded motion" -else - note " SKIP replay (leader still running)" - FAILURES=$((FAILURES + 1)) fi rule "result" From 63cc3386233c33c283d02a4aace0b29af51e1eea Mon Sep 17 00:00:00 2001 From: Michael Johnson Date: Wed, 2 Sep 2026 16:29:49 +0100 Subject: [PATCH 04/22] The camera does fit: the arm is mounted 180 degrees round Three files told the reader the camera cannot be attached with the arm on, and sent them to record episodes with --no-camera. That has not been true since the arm was re-mounted rotated 180 degrees -- option 1 of GitHub #2, where the camera clears the arm and the price is reach in the forward direction. Recording with the camera works; --no-camera is now what you use on a robot whose camera is off or fouled, not the standing advice. The re-mount left something behind, recorded in the same places rather than fixed here because this branch is teleop and teleop is joint-space: arm_mount_joint in mote.urdf.xacro is still rpy="0 0 0" and so describes the orientation the arm no longer has. Jog, taught poses, teleop and replay are unaffected -- none of them asks where the gripper is in the base frame -- but TF and RViz draw the arm facing the wrong way, and anything reasoning in base coordinates (a fetch standoff, an IK stack) would be 180 degrees out. mkdocs build --strict clean; TELEOP.md is mounted at arm/teleop.md. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_017hfWyNtkVh16hyvCK8K9cd --- CLAUDE.md | 10 ++++++++-- mote_arm/README.md | 17 +++++++++++++---- mote_arm/TELEOP.md | 7 ++++--- 3 files changed, 25 insertions(+), 9 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 013dd10..dc29b02 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -931,8 +931,14 @@ section. Contains: reversals — the last two are the buzz check that bounds how high Kp may go), writes the trace to `~/.mote/arm_gain_sweeps/`, and restores the gains and limpness it started with, so a sweep on its own changes nothing. -- **Physical note (GitHub #2):** the camera doesn't fit with the arm attached — - an unresolved mechanical clash, tracked separately, not addressed here. +- **Physical note (GitHub #2):** the camera and the arm fouled each other, so + the arm is mounted **rotated 180 degrees** (option 1 of that issue). The + camera clears it, barely, and the cost is forward reach. **`arm_mount_joint` + in `mote.urdf.xacro` is still `rpy="0 0 0"`** and so describes the old + orientation: joint-space work is unaffected (nothing there asks where the + gripper is in the base frame), but TF draws the arm facing the wrong way and + anything reasoning in base coordinates — a fetch standoff, an IK stack — + would be 180 degrees out. The arm *is* part of the mission bringup now (it is in `mote_hardware`), but it stays limp until a controller claims it. diff --git a/mote_arm/README.md b/mote_arm/README.md index bd33fc1..68c51e9 100644 --- a/mote_arm/README.md +++ b/mote_arm/README.md @@ -454,10 +454,19 @@ client-side purely for immediate feedback. use in place of its stubs. - `controller_manager/switch_controller` — activate to hold, deactivate to limp. -## Physical note (GitHub #2) - -The camera does not fit when the SO-101 arm is attached. That is an unresolved -mechanical clash, tracked separately — not addressed here. +## Physical note: the arm is mounted backwards (GitHub #2) + +The camera and the arm fouled each other on the top plate. The arm is therefore +mounted **rotated 180 degrees**, which is option 1 of that issue: the camera +clears it — only just — and the price is reach in the forward direction. + +Two consequences. `arm_mount_joint` in `mote.urdf.xacro` still carries +`rpy="0 0 0"`, so TF and RViz draw the arm facing the way it used to, not the +way it does; joint-space work (jog, taught poses, teleop, replay) is unaffected, +because none of it asks where the gripper is in the base frame, but anything +that does — a fetch standoff pose, an IK stack — would be 180 degrees out. +And the clearance is tight rather than comfortable, so re-check it after any +re-mount. ## Calibration diff --git a/mote_arm/TELEOP.md b/mote_arm/TELEOP.md index ea849aa..c1a3172 100644 --- a/mote_arm/TELEOP.md +++ b/mote_arm/TELEOP.md @@ -136,9 +136,10 @@ The action is the *mirror's* output, not the leader's pose, because a policy replaces whatever produces goals — and it is read off the trajectory topic rather than from the mirror, so a session driven by `arm-jog` records too. -> **The camera does not physically fit with the arm attached** (GitHub #2). -> Until that is resolved, record state-only with `--no-camera` — the capture, -> the export and the replay all handle a camera-less dataset. +> The arm is mounted **rotated 180 degrees** so the camera clears it (GitHub +> #2), so episodes do record camera frames. Use `--no-camera` for a robot whose +> camera is off or fouled: the capture, the export and the replay all handle a +> camera-less dataset. Captures land in `$MOTE_HOME/episodes//` — per-robot state, alongside maps, zones and taught poses. The format is documented in `mote_arm/episode.py`: From 86287ba88e1376e02cd70b098b025d132806858a Mon Sep 17 00:00:00 2001 From: Michael Johnson Date: Wed, 2 Sep 2026 16:39:35 +0100 Subject: [PATCH 05/22] virtual_leader: show where the joint is while you drive it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "Hold the key past the soft limit and watch it stop" was not something the operator could judge. The help printed each joint's limits but never its position, `p` printed positions but only once and was not in the help, and nothing at all was printed while a key was held — so an arm that had stopped at its limit looked exactly like an arm that had stalled, lost its link, or given up. While a key repeats, one line is now rewritten in place for the joints being driven: where the joint is, where it is being sent, and the band it is in. When the leader's pose is clamped it says so outright: shoulder_pan +0.224 AT LIMIT (max +0.229) That is the check turned into a reading rather than an inference, and it doubles as the diagnostic for the opposite case: a target that keeps advancing while the measured value sticks is the arm failing to follow, which is a different fault from reaching a limit and looks identical without the numbers. `_out` clears the live line before writing, so ordinary messages never land on top of it, and it is cleared when the leader goes idle or quits rather than being left on screen. `p` is now in the help. Verified through a pty against the mock arm: 'q' held for 3 s drove shoulder_pan from +0.010 to its +0.229 limit and the line switched to AT LIMIT, then cleared on quit. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_017hfWyNtkVh16hyvCK8K9cd --- mote_arm/mote_arm/virtual_leader.py | 69 ++++++++++++++++++++++++++++- 1 file changed, 68 insertions(+), 1 deletion(-) diff --git a/mote_arm/mote_arm/virtual_leader.py b/mote_arm/mote_arm/virtual_leader.py index b8d3794..2e2e20f 100644 --- a/mote_arm/mote_arm/virtual_leader.py +++ b/mote_arm/mote_arm/virtual_leader.py @@ -56,6 +56,8 @@ SYNC_KEY = "0" QUIT_KEYS = ("x", "\x03", "\x04") PUBLISH_RATE_HZ = 20.0 +# Slow enough to read while a joint is moving, fast enough to look continuous. +LIVE_LINE_PERIOD = 0.15 class VirtualLeader(Node): @@ -108,6 +110,14 @@ def press(self, name: str, direction: float, now: float) -> None: self._direction[name] = direction self._key_time[name] = now + def driving(self, now: float) -> list[str]: + """Joints whose key is still repeating, in config order.""" + return [ + j.name + for j in self.cfg.joints + if now - self._key_time.get(j.name, -1e9) <= self.key_timeout + ] + def step(self, now: float, dt: float) -> bool: """Advance the leader pose; True if it is live (an input is being held).""" live = False @@ -133,12 +143,61 @@ def set_estop(self, engaged: bool) -> None: self._estop_pub.publish(Bool(data=engaged)) +# True while a live status line is on screen, waiting to be overwritten in place. +# Any ordinary line must clear it first, or the two overlap. +_live_line = False + + def _out(text: str = "") -> None: """Print in a raw terminal, where a bare newline would stair-step.""" + global _live_line + if _live_line: + sys.stdout.write("\r\033[K") + _live_line = False sys.stdout.write(text + "\r\n") sys.stdout.flush() +def _live(text: str) -> None: + """Rewrite one status line in place, rather than scrolling a new one.""" + global _live_line + sys.stdout.write("\r\033[K" + text) + sys.stdout.flush() + _live_line = True + + +def _clear_live() -> None: + global _live_line + if _live_line: + sys.stdout.write("\r\033[K") + sys.stdout.flush() + _live_line = False + + +def _driving_line(node: VirtualLeader, names: list[str]) -> str: + """Where the driven joints are, and whether they are against a limit. + + "Hold the key past the soft limit and watch it stop" is not something an + operator can judge from an arm that has simply stopped moving: it looks the + same as a stall, a dropped link or a servo that gave up. So say which it is. + """ + measured = node.measured() + parts = [] + for name in names: + joint = node.cfg.joint(name) + now = measured.get(name, float("nan")) + target = node.pose.get(name, now) + if target in (joint.min_rad, joint.max_rad): + edge = "min" if target == joint.min_rad else "max" + parts.append(f"{name} {now:+.3f} AT LIMIT ({edge} {target:+.3f})") + else: + parts.append( + f"{name} {now:+.3f} -> {target:+.3f} " + f"[{joint.min_rad:+.3f}, {joint.max_rad:+.3f}]" + ) + return " " + " ".join(parts) + + def _help(node: VirtualLeader) -> None: _out() _out(f"speed {node.speed:.2f} rad/s deadman {node.key_timeout:.2f} s") @@ -147,7 +206,8 @@ def _help(node: VirtualLeader) -> None: f" {pair[0]} / {pair[1]} {joint.name:<14} " f"limits [{joint.min_rad:+.3f}, {joint.max_rad:+.3f}]" ) - _out(" SPACE panic (torque off) z clear 0 re-sync [ ] speed x quit") + _out(" SPACE panic (torque off) z clear 0 re-sync [ ] speed") + _out(" p all joint positions ? this help x quit") def _status(node: VirtualLeader, estopped: bool) -> None: @@ -164,6 +224,7 @@ def _drive(node: VirtualLeader) -> None: period = 1.0 / PUBLISH_RATE_HZ estopped = False idle_since = time.monotonic() + last_line = 0.0 _out("virtual leader — the arm mirrors this pose. '?' for keys, 'x' to quit.") if not node.wait_for_states(): @@ -178,6 +239,7 @@ def _drive(node: VirtualLeader) -> None: while select.select([sys.stdin], [], [], 0)[0]: key = sys.stdin.read(1) if key in QUIT_KEYS: + _clear_live() return if key in node.keys: name, direction = node.keys[key] @@ -207,11 +269,16 @@ def _drive(node: VirtualLeader) -> None: elif key == "p": _status(node, estopped) + driving = node.driving(now) live = node.step(now, period) and not estopped if live: node.publish() idle_since = now + if now - last_line >= LIVE_LINE_PERIOD: + last_line = now + _live(_driving_line(node, driving)) elif now - idle_since > node.key_timeout: + _clear_live() # Idle: the leader must not sit ahead of the arm, or resuming would # pay out the accumulated difference as an unrequested move. node.sync() From b5044fb985a25902574109c8a5dcf6e1e2463838 Mon Sep 17 00:00:00 2001 From: Michael Johnson Date: Wed, 2 Sep 2026 16:49:26 +0100 Subject: [PATCH 06/22] arm_mirror: measure where the motion is going instead of guessing Teleop on the arm is stuttery and falls short of the joint's range, and three candidate causes were indistinguishable from outside the process: the mirror not ticking at the rate it claims, leader poses arriving in gaps, or the arm not achieving the velocity it is asked for. Two rounds of hypothesis got nowhere -- raising the leader's key timeout changed nothing, and the trajectory topic measured a clean 20 Hz -- so each candidate is now a number instead. pixi run arm-mirror --ros-args -p diagnose:=true prints, twice a second, for whichever joint moved most in the window: diag tick 19.7Hz worst 51.9ms | leader 19.7Hz worst gap 51.4ms | shoulder_pan cmd 0.296 arm 0.148 rad/s lag +0.090 rad | tracking Reading it: cmd at the rate limit with arm well below it, and a lag that grows, is the arm failing to follow. Both low is the mirror. A leader gap over the deadman is the input path -- which the deadman then reports as HOLDING, so the state on the end of the line says whether it fired. Validated against the mock follower capped at 0.15 rad/s while the mirror was allowed 0.5: the line reported cmd 0.296, arm 0.148, lag growing to 0.090 rad, which is the signature the real arm is showing. So the instrument reproduces the symptom on demand, on a workstation, with no arm attached. LeaderMirror gained read-only `commanded` and `measured` properties for this; nothing else reaches into its state. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_017hfWyNtkVh16hyvCK8K9cd --- mote_arm/mote_arm/mirror.py | 81 +++++++++++++++++++++++++++++++++++++ mote_arm/mote_arm/teleop.py | 10 +++++ 2 files changed, 91 insertions(+) diff --git a/mote_arm/mote_arm/mirror.py b/mote_arm/mote_arm/mirror.py index 5f4c69f..fbfaf7d 100644 --- a/mote_arm/mote_arm/mirror.py +++ b/mote_arm/mote_arm/mirror.py @@ -50,6 +50,78 @@ def latched(depth: int = 1) -> QoSProfile: return qos +class Diagnostics: + """Where the motion is being lost, measured rather than reasoned about. + + Teleop that stutters or falls short of its range has three candidate causes + and they are indistinguishable from the outside: the mirror not ticking at + the rate it claims, leader poses arriving in gaps, or the arm not achieving + the velocity it is being asked for. Each is a number, so each is printed: + + tick how fast this loop really runs, and its worst period + leader arrival rate of leader/joint_states, and the worst gap between two + cmd rate the commanded pose advances -- what the mirror is asking for + arm rate the measured pose advances -- what the arm actually did + lag how far the arm trails the command right now + + cmd at the rate limit with arm well below it, and a lag that grows, is the + arm failing to follow. Both low is the mirror. A leader gap over the deadman + is the input path. Reported for the joint that moved most over the window, + since that is the one being driven. + """ + + def __init__(self, node: "ArmMirror", period: float = 0.5): + self._node = node + self._period = period + self._reset(time.monotonic()) + self.leader_stamps: list[float] = [] + + def _reset(self, now: float) -> None: + self._start = now + self._ticks = 0 + self._dt_max = 0.0 + self._last_tick = now + self._commanded0 = self._node.mirror.commanded + self._measured0 = self._node.mirror.measured + self.leader_stamps = [] + + def on_leader(self, now: float) -> None: + self.leader_stamps.append(now) + + def tick(self, now: float) -> None: + self._ticks += 1 + self._dt_max = max(self._dt_max, now - self._last_tick) + self._last_tick = now + elapsed = now - self._start + if elapsed < self._period: + return + + commanded = self._node.mirror.commanded + measured = self._node.mirror.measured + moved = {n: abs(v - self._commanded0.get(n, v)) for n, v in commanded.items()} + joint = max(moved, key=moved.get, default=None) + + gaps = [b - a for a, b in zip(self.leader_stamps, self.leader_stamps[1:])] + parts = [ + f"tick {self._ticks / elapsed:4.1f}Hz worst {self._dt_max * 1e3:5.1f}ms", + f"leader {len(self.leader_stamps) / elapsed:4.1f}Hz " + f"worst gap {max(gaps, default=0.0) * 1e3:5.1f}ms", + ] + if joint is not None: + cmd_rate = moved[joint] / elapsed + arm_rate = ( + abs(measured.get(joint, 0.0) - self._measured0.get(joint, 0.0)) + / elapsed + ) + lag = abs(commanded[joint] - measured.get(joint, commanded[joint])) + parts.append( + f"{joint} cmd {cmd_rate:.3f} arm {arm_rate:.3f} rad/s lag {lag:+.3f} rad" + ) + parts.append(self._node.mirror.state) + self._node.get_logger().info("diag " + " | ".join(parts)) + self._reset(now) + + class ArmMirror(Node): def __init__(self): super().__init__("arm_mirror") @@ -57,6 +129,8 @@ def __init__(self): self.declare_parameter("rate", 20.0) self.declare_parameter("max_velocity", MirrorLimits.max_velocity) self.declare_parameter("deadman_timeout", MirrorLimits.deadman_timeout) + # `pixi run arm-mirror --ros-args -p diagnose:=true` + self.declare_parameter("diagnose", False) path = self.get_parameter("robot_yaml").get_parameter_value().string_value self.cfg = config.ArmConfig.from_yaml_file(path) if path else config.load() @@ -75,6 +149,9 @@ def __init__(self): self.create_subscription(Bool, "teleop/estop", self._on_estop, latched()) self._reported = None + self.diagnostics = ( + Diagnostics(self) if self.get_parameter("diagnose").value else None + ) self._estop_requested = False self.period = 1.0 / max(1.0, self.get_parameter("rate").value) @@ -88,6 +165,8 @@ def _now(self) -> float: return self.get_clock().now().nanoseconds * 1e-9 def _on_leader(self, msg: JointState) -> None: + if self.diagnostics is not None: + self.diagnostics.on_leader(time.monotonic()) self.mirror.on_leader(dict(zip(msg.name, msg.position)), self._now()) def _on_states(self, msg: JointState) -> None: @@ -113,6 +192,8 @@ def _apply_estop(self) -> None: self.get_logger().info("panic cleared; following again") def tick(self) -> None: + if self.diagnostics is not None: + self.diagnostics.tick(time.monotonic()) self._apply_estop() goal = self.mirror.update(self._now(), self.period) if goal: diff --git a/mote_arm/mote_arm/teleop.py b/mote_arm/mote_arm/teleop.py index f5119d6..6f79d5e 100644 --- a/mote_arm/mote_arm/teleop.py +++ b/mote_arm/mote_arm/teleop.py @@ -88,6 +88,16 @@ def __init__( def estopped(self) -> bool: return self._estop + @property + def commanded(self) -> dict[str, float]: + """The pose being asked for — read-only, for diagnostics.""" + return dict(self._commanded) + + @property + def measured(self) -> dict[str, float]: + """The pose last reported by the arm — read-only, for diagnostics.""" + return dict(self._measured) + def on_leader(self, pose: Mapping[str, float], now: float) -> None: """Record a virtual-leader pose. Unknown joint names are ignored.""" self._leader = {n: v for n, v in pose.items() if n in self._joints} From e741c444c7ea1fbb0832a958115bf7ef1e0b32f9 Mon Sep 17 00:00:00 2001 From: Michael Johnson Date: Wed, 2 Sep 2026 17:01:44 +0100 Subject: [PATCH 07/22] The arm stops where the goal register runs out, and now says so Found at the bench, and it is not a stall. shoulder_lift capped at -0.865 rad every time, in one direction only, while the same joint drove to +1.787 against its +1.775 limit -- so it was not gravity and not load, which is what the operator's "same angle every time, and I can move it further the other way" established. A goal is written to a 12-bit register, and mote_hardware clamps it: return std::max(0, std::min(ARM_COUNTS_PER_REV - 1, counts)); At 2*pi/4096 rad per count, -0.865 rad is 564 counts. A joint whose zero sits 564 counts up therefore has count 0 as its floor: the soft band says -1.775, the register cannot express past -0.865, and the goal silently saturates there. Both copies of that clamp already carried a comment about saturating at 0 or 4095 -- nothing checked for it, so it was a comment about a defect rather than a guard against one. JointSpec now computes the band the register can actually address (`reachable_min`/`reachable_max`) and names the discrepancy (`unreachable`); ArmConfig.problems collects them. Reported rather than raised, because a robot with one over-wide band should still come up and drive its other five joints. The virtual leader's help now lists the *reachable* limits, says what they were narrowed from, and prints the warning; arm_mirror logs it at startup. The remedy is `pixi run arm-calibrate`, which re-centres the zero by writing the servo's homing offset -- which is what moves the addressable window. Second defect, from the same session and visible in the diagnostics: the mirror commanded a stationary arm at 0.25 rad/s for four seconds and let the lag grow without bound, 0.087 -> 0.812 rad, straining the servo against a target ever further ahead and lurching on every re-seed. arm-pose go has had a stall guard since it was written; the mirror had none. The commanded pose may now never lead the measured one by more than `max_lag` (0.15 rad, well above the 0.01-0.03 of ordinary droop), so it waits for the arm instead of running away, and reports which joint is not following. Reproduced against a mock follower pinned at zero speed: lag pinned at 0.150 rad instead of growing, with the joint named. 222 tests pass, including three that pin the register arithmetic for plain and inverted joints and three for the stall clamp. Not fixed here: the same clamp in mote_hardware/include/mote_hardware/arm_joint.hpp is still silent, and it is the authoritative one. Filed separately. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_017hfWyNtkVh16hyvCK8K9cd --- mote_arm/mote_arm/config.py | 45 +++++++++++++++++++++++ mote_arm/mote_arm/mirror.py | 20 ++++++++++- mote_arm/mote_arm/teleop.py | 25 +++++++++++++ mote_arm/mote_arm/virtual_leader.py | 25 ++++++++++--- mote_arm/test/test_config.py | 34 ++++++++++++++++++ mote_arm/test/test_teleop.py | 55 +++++++++++++++++++++++++++++ 6 files changed, 199 insertions(+), 5 deletions(-) diff --git a/mote_arm/mote_arm/config.py b/mote_arm/mote_arm/config.py index 011bd91..d324918 100644 --- a/mote_arm/mote_arm/config.py +++ b/mote_arm/mote_arm/config.py @@ -51,6 +51,42 @@ def clamp_rad(self, rad: float) -> float: """Clamp a commanded angle to the joint's soft limits.""" return max(self.min_rad, min(self.max_rad, rad)) + @property + def reachable_min(self) -> float: + """Lowest angle the 0-4095 goal register can actually express.""" + edge = (0 - self.zero_counts) * RAD_PER_COUNT * self.sign + other = (COUNTS_PER_REV - 1 - self.zero_counts) * RAD_PER_COUNT * self.sign + return max(self.min_rad, min(edge, other)) + + @property + def reachable_max(self) -> float: + """Highest angle the 0-4095 goal register can actually express.""" + edge = (0 - self.zero_counts) * RAD_PER_COUNT * self.sign + other = (COUNTS_PER_REV - 1 - self.zero_counts) * RAD_PER_COUNT * self.sign + return min(self.max_rad, max(edge, other)) + + @property + def unreachable(self) -> str | None: + """Why part of this joint's soft band cannot be commanded, if any. + + A goal is written to a 12-bit register, so an angle outside + [0, 4095] counts about ``zero`` saturates at the edge instead of being + refused. The joint then stops at the same angle every time, in one + direction only, whatever the load — which looks like a stall and is not + one. A soft band wider than the register can address is therefore a + configuration error worth naming, not a limit to discover by driving + into it. + """ + lo, hi = self.reachable_min, self.reachable_max + if lo <= self.min_rad and hi >= self.max_rad: + return None + return ( + f"joint {self.name!r}: soft limits [{self.min_rad:+.3f}, " + f"{self.max_rad:+.3f}] but zero={self.zero_counts} leaves only " + f"[{lo:+.3f}, {hi:+.3f}] addressable in the 0-{COUNTS_PER_REV - 1} " + "goal register — re-run `pixi run arm-calibrate` to re-centre it" + ) + def counts_to_rad(self, counts: int) -> float: """Convert a raw encoder reading to radians about the joint zero.""" return self.sign * (counts - self.zero_counts) * RAD_PER_COUNT @@ -106,6 +142,15 @@ def ids(self) -> list[int]: def names(self) -> list[str]: return [j.name for j in self.joints] + @property + def problems(self) -> list[str]: + """Configuration faults worth warning about, but not worth refusing over. + + Reported rather than raised: a robot with one over-wide band should + still come up and drive the rest of its joints. + """ + return [p for p in (j.unreachable for j in self.joints) if p] + def joint(self, name: str) -> JointSpec: for j in self.joints: if j.name == name: diff --git a/mote_arm/mote_arm/mirror.py b/mote_arm/mote_arm/mirror.py index fbfaf7d..bd0fc86 100644 --- a/mote_arm/mote_arm/mirror.py +++ b/mote_arm/mote_arm/mirror.py @@ -117,7 +117,10 @@ def tick(self, now: float) -> None: parts.append( f"{joint} cmd {cmd_rate:.3f} arm {arm_rate:.3f} rad/s lag {lag:+.3f} rad" ) - parts.append(self._node.mirror.state) + state = self._node.mirror.state + if self._node.mirror.stalled: + state += " STALLED:" + ",".join(self._node.mirror.stalled) + parts.append(state) self._node.get_logger().info("diag " + " | ".join(parts)) self._reset(now) @@ -149,12 +152,16 @@ def __init__(self): self.create_subscription(Bool, "teleop/estop", self._on_estop, latched()) self._reported = None + self._stalled = [] self.diagnostics = ( Diagnostics(self) if self.get_parameter("diagnose").value else None ) self._estop_requested = False self.period = 1.0 / max(1.0, self.get_parameter("rate").value) + for problem in self.cfg.problems: + self.get_logger().warn(problem) + limits = self.mirror.limits self.get_logger().info( f"arm_mirror up: max {limits.max_velocity:.2f} rad/s, deadman " @@ -202,6 +209,17 @@ def tick(self) -> None: # finish in time just runs ahead of the hardware. self.arm.send(goal, self.period) + if self.mirror.stalled != self._stalled: + self._stalled = list(self.mirror.stalled) + if self._stalled: + self.get_logger().warn( + f"not following: {', '.join(self._stalled)} is " + f"{self.mirror.limits.max_lag:.2f} rad behind and not moving — " + "holding the command there rather than driving further ahead" + ) + else: + self.get_logger().info("following again") + if self.mirror.state != self._reported: self._reported = self.mirror.state if self.mirror.state == HOLDING: diff --git a/mote_arm/mote_arm/teleop.py b/mote_arm/mote_arm/teleop.py index 6f79d5e..0b89418 100644 --- a/mote_arm/mote_arm/teleop.py +++ b/mote_arm/mote_arm/teleop.py @@ -44,12 +44,23 @@ class MirrorLimits: # terminal's key-repeat gap, short enough that a released key stops the arm # while it is still obviously connected to the key. deadman_timeout: float = 0.4 + # Radians the commanded pose may lead the measured one. A position servo + # that cannot reach its target -- gravity on the lifting joint, a hand on + # the arm, something in the way -- does not say so; it simply sits there + # while the command runs away, straining harder every tick against a target + # it will never meet, and every re-seed then snaps the command back. So the + # command is not allowed to run away: it waits for the arm. Well above the + # 0.01-0.03 rad of ordinary proportional droop, so normal following is + # untouched. + max_lag: float = 0.15 def __post_init__(self) -> None: if self.max_velocity <= 0: raise ValueError("max_velocity must be positive") if self.deadman_timeout <= 0: raise ValueError("deadman_timeout must be positive") + if self.max_lag <= 0: + raise ValueError("max_lag must be positive") # What the mirror is doing, for logging and for tests to assert on. @@ -82,6 +93,9 @@ def __init__( # arm's present position: that halts the residual travel towards the # last setpoint instead of letting it coast there. self._halt_pending = False + # Joints whose command is being held back because the arm is not + # following. Empty is the normal case. + self.stalled: list[str] = [] self.state = WAITING @property @@ -159,15 +173,26 @@ def update(self, now: float, dt: float) -> dict[str, float] | None: max_step = self.limits.max_velocity * max(0.0, dt) goal: dict[str, float] = {} + stalled: list[str] = [] for name, target in self._leader.items(): joint = self._joints[name] start = self._commanded.get(name, self._measured.get(name)) if start is None: continue stepped = advance(start, joint.clamp_rad(target), max_step) + # Never lead the arm by more than max_lag: if it is not following, + # the command stops advancing and waits rather than running away. + here = self._measured.get(name) + if here is not None: + stepped = max( + here - self.limits.max_lag, min(here + self.limits.max_lag, stepped) + ) + if abs(stepped - here) >= self.limits.max_lag - 1e-9: + stalled.append(name) self._commanded[name] = stepped goal[name] = stepped + self.stalled = sorted(stalled) self.state = TRACKING return goal or None diff --git a/mote_arm/mote_arm/virtual_leader.py b/mote_arm/mote_arm/virtual_leader.py index 2e2e20f..53dc5bd 100644 --- a/mote_arm/mote_arm/virtual_leader.py +++ b/mote_arm/mote_arm/virtual_leader.py @@ -46,6 +46,7 @@ from std_msgs.msg import Bool from mote_arm import cli, config, teleop +from mote_arm.teleop import MirrorLimits from mote_arm.mirror import latched # Key pairs in joint order: the top row raises a joint, the home row lowers it. @@ -189,12 +190,20 @@ def _driving_line(node: VirtualLeader, names: list[str]) -> str: target = node.pose.get(name, now) if target in (joint.min_rad, joint.max_rad): edge = "min" if target == joint.min_rad else "max" - parts.append(f"{name} {now:+.3f} AT LIMIT ({edge} {target:+.3f})") + note = "" if not joint.unreachable else " — but the register edge" + note += "" if not joint.unreachable else " bites first, see '?'" + parts.append(f"{name} {now:+.3f} AT LIMIT ({edge} {target:+.3f}){note}") else: - parts.append( + line = ( f"{name} {now:+.3f} -> {target:+.3f} " f"[{joint.min_rad:+.3f}, {joint.max_rad:+.3f}]" ) + # The arm is being asked for something it is not doing. Saying so + # here is the difference between "why is nothing happening" and + # knowing the command is fine and the joint is not moving. + if now == now and abs(target - now) > MirrorLimits.max_lag: + line += " NOT FOLLOWING" + parts.append(line) return " " + " ".join(parts) @@ -202,10 +211,18 @@ def _help(node: VirtualLeader) -> None: _out() _out(f"speed {node.speed:.2f} rad/s deadman {node.key_timeout:.2f} s") for pair, joint in zip(KEY_PAIRS, node.cfg.joints): - _out( + # The reachable band, not the configured one: an angle the 12-bit goal + # register cannot express is not a limit you can drive to, it is one the + # arm stops at without saying why. + line = ( f" {pair[0]} / {pair[1]} {joint.name:<14} " - f"limits [{joint.min_rad:+.3f}, {joint.max_rad:+.3f}]" + f"limits [{joint.reachable_min:+.3f}, {joint.reachable_max:+.3f}]" ) + if joint.unreachable: + line += f" (narrowed from [{joint.min_rad:+.3f}, {joint.max_rad:+.3f}])" + _out(line) + for problem in node.cfg.problems: + _out(f" WARNING {problem}") _out(" SPACE panic (torque off) z clear 0 re-sync [ ] speed") _out(" p all joint positions ? this help x quit") diff --git a/mote_arm/test/test_config.py b/mote_arm/test/test_config.py index 9f85310..0e2ba53 100644 --- a/mote_arm/test/test_config.py +++ b/mote_arm/test/test_config.py @@ -207,3 +207,37 @@ def test_real_robot_yaml_gains_are_sane(): pytest.skip("robot.yaml not found in source tree") g = ArmConfig.from_yaml_file(str(robot_yaml)).gains assert 0 < g.kp <= 254 + + +def test_a_band_the_goal_register_cannot_address_is_named(): + """Measured on the arm: shoulder_lift stopped dead at -0.865 rad every time, + in one direction only, whatever the load — because zero=564 puts -0.865 rad + at encoder count 0 and the goal saturates there silently.""" + joint = JointSpec( + name="shoulder_lift", id=2, min_rad=-1.775, max_rad=1.775, zero_counts=564 + ) + assert joint.reachable_min == pytest.approx(-0.865, abs=1e-3) + assert joint.reachable_max == pytest.approx(1.775) + assert "only [-0.865, +1.775] addressable" in joint.unreachable + + +def test_a_centred_joint_has_no_problem(): + joint = JointSpec( + name="elbow_flex", id=3, min_rad=-1.662, max_rad=1.662, zero_counts=2048 + ) + assert joint.unreachable is None + assert joint.reachable_min == pytest.approx(-1.662) + + +def test_an_inverted_joint_saturates_at_the_opposite_end(): + """Inverting the joint swaps which encoder edge bites: the same zero that + caps a normal joint's minimum caps an inverted one's maximum.""" + plain = JointSpec(name="a", id=1, min_rad=-1.5, max_rad=1.5, zero_counts=564) + flipped = JointSpec( + name="b", id=2, min_rad=-1.5, max_rad=1.5, zero_counts=564, invert=True + ) + assert plain.reachable_min == pytest.approx(-0.865, abs=1e-3) + assert plain.reachable_max == pytest.approx(1.5) + assert flipped.reachable_max == pytest.approx(0.865, abs=1e-3) + assert flipped.reachable_min == pytest.approx(-1.5) + assert flipped.unreachable is not None diff --git a/mote_arm/test/test_teleop.py b/mote_arm/test/test_teleop.py index 47a536b..468d48a 100644 --- a/mote_arm/test/test_teleop.py +++ b/mote_arm/test/test_teleop.py @@ -155,3 +155,58 @@ def test_limits_must_be_positive(): MirrorLimits(max_velocity=0.0) with pytest.raises(ValueError): MirrorLimits(deadman_timeout=-1.0) + + +def test_the_command_never_runs_away_from_an_arm_that_is_not_moving(): + """A stalled joint must not be commanded further and further ahead. + + Measured on the real arm: shoulder_lift stopped dead at -0.865 rad while the + mirror went on commanding 0.25 rad/s into it, and the lag grew without bound + (0.087 -> 0.812 rad over four seconds). The servo cannot report that it has + given up, so the only evidence is that the measured pose is not changing. + """ + m = mirror(max_velocity=1.0, max_lag=0.15) + m.on_measured({"elbow_flex": 0.0}) + + goal = None + for i in range(60): # 3 s of a held key, with the arm never moving + now = i * DT + m.on_leader({"elbow_flex": 1.0}, now) + goal = m.update(now, DT) + m.on_measured({"elbow_flex": 0.0}) + + assert goal["elbow_flex"] == pytest.approx(0.15) + assert m.stalled == ["elbow_flex"] + + +def test_a_following_arm_is_never_reported_as_stalled(): + m = mirror(max_velocity=1.0, max_lag=0.15) + m.on_measured({"elbow_flex": 0.0}) + for i in range(40): + now = i * DT + m.on_leader({"elbow_flex": 1.0}, now) + goal = m.update(now, DT) + # The arm keeps up, trailing by the ordinary droop. + m.on_measured({"elbow_flex": goal["elbow_flex"] - 0.02}) + assert m.stalled == [] + assert goal["elbow_flex"] > 0.5 + + +def test_the_command_resumes_once_the_arm_moves_again(): + m = mirror(max_velocity=1.0, max_lag=0.15) + m.on_measured({"elbow_flex": 0.0}) + for i in range(40): + m.on_leader({"elbow_flex": 1.0}, i * DT) + m.update(i * DT, DT) + assert m.stalled == ["elbow_flex"] + + # Whatever was holding it lets go and the joint tracks its command again, + # trailing by ordinary droop. It cannot overshoot the command on its own — + # a position servo goes where it is told and no further. + for i in range(10): + now = 3.0 + i * DT + m.on_measured({"elbow_flex": m.commanded["elbow_flex"] - 0.02}) + m.on_leader({"elbow_flex": 1.0}, now) + goal = m.update(now, DT) + assert goal["elbow_flex"] > 0.5 + assert m.stalled == [] From 425a41f100bc01a47db7fe62d86b5313c8dfea8e Mon Sep 17 00:00:00 2001 From: Michael Johnson Date: Wed, 2 Sep 2026 17:16:49 +0100 Subject: [PATCH 08/22] arm-check: show the servo's own goal-range limits Diagnostic tooling the bench run needed, and the only piece of arm state nothing here has ever looked at. shoulder_lift on mote-01 stops dead at -0.865 rad while reaching +1.787 in the other direction. Everything above the servo is ruled out by numbers: the URDF limit is +-3.14, MoteHardware clamps to arm.yaml's +-1.7754, and rad_to_counts' register clamp does not bite with zero=2048. arm-jog -- one trajectory per keypress, hundreds of milliseconds long -- hits the same wall as teleop's 20 Hz stream, so it is not the command pattern either. That leaves the servo. STS registers 9-12 hold Min/Max_Angle_Limit, and in position mode the servo refuses a goal outside them: silently, in one direction, at any load. Which is indistinguishable from running out of torque, and invisible in robot.yaml, arm.yaml, the URDF and every tool in this package, because none of them read those registers. The bus layer knew torque, mode, gains, homing offset, position, load, voltage and temperature -- not this. `pixi run arm-check` now prints, per joint, the band the servo will accept in both counts and radians beside the configured band, and flags any joint whose configuration asks for more than the servo allows. Read twice and trusted only on agreement, the same rule read_gains follows, because a single read of EEPROM on this bus has been seen to come back garbled. It is a read, not a write: nothing here changes a limit. Whether these should be managed the way arm.yaml manages zeros and gains is a separate question, and this is what makes it answerable. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_017hfWyNtkVh16hyvCK8K9cd --- mote_arm/mote_arm/arm_check.py | 41 ++++++++++++++++++++++++++++++++++ mote_arm/mote_arm/bus.py | 29 ++++++++++++++++++++++++ 2 files changed, 70 insertions(+) diff --git a/mote_arm/mote_arm/arm_check.py b/mote_arm/mote_arm/arm_check.py index 40395f1..d215d97 100644 --- a/mote_arm/mote_arm/arm_check.py +++ b/mote_arm/mote_arm/arm_check.py @@ -27,6 +27,44 @@ def _resolve_device(port: str) -> str: return os.path.realpath(port) +def _report_angle_limits(limits: list) -> None: + """What the servo itself will accept, against what the config asks for. + + These registers live only in the servo. A joint whose configured band runs + past them stops dead at the same angle every time, in one direction, at any + load -- indistinguishable from running out of torque, and invisible in + robot.yaml, arm.yaml, the URDF and every other tool here. + """ + if not limits: + return + print("\nservo goal-range limits (EEPROM, registers 9-12):") + print(f"{'joint':<14} {'min':>5} {'max':>5} {'accepts (rad)':>18} configured") + problems = [] + for joint, band in limits: + if band is None: + print(f"{joint.name:<14} --- --- (could not read)") + continue + low, high = band + # counts_to_rad honours `invert`, so an inverted joint's low count is + # its high angle; order them by angle, not by register. + angles = sorted((joint.counts_to_rad(low), joint.counts_to_rad(high))) + note = "" + if angles[0] > joint.min_rad + 1e-6 or angles[1] < joint.max_rad - 1e-6: + note = " <-- NARROWER THAN CONFIGURED" + problems.append(joint.name) + print( + f"{joint.name:<14} {low:>5} {high:>5} " + f"[{angles[0]:+.3f}, {angles[1]:+.3f}] " + f"[{joint.min_rad:+.3f}, {joint.max_rad:+.3f}]{note}" + ) + if problems: + print( + f"\n{', '.join(problems)}: the servo will refuse goals outside its own " + "band, so the joint stops there whatever robot.yaml and arm.yaml say. " + "Widen the register or narrow the configured limits to match." + ) + + def main() -> None: parser = argparse.ArgumentParser(description="SO-101 arm bus check") parser.add_argument("--robot-yaml", default="", help="override robot.yaml path") @@ -71,6 +109,7 @@ def main() -> None: f"\n{'joint':<14} {'id':>3} {'pos':>5} {'rad':>7} " f"{'volt':>5} {'temp':>4} {'load':>6}" ) + limits: list = [] for joint in cfg.joints: health = bus.read_health(joint.id) if bus.ping(joint.id) else None if health is None: @@ -83,6 +122,8 @@ def main() -> None: f"{joint.counts_to_rad(health.position):>+7.3f} " f"{health.voltage:>5.1f} {health.temperature:>4} {health.load:>6}" ) + limits.append((joint, bus.read_angle_limits(joint.id))) + _report_angle_limits(limits) finally: bus.close() diff --git a/mote_arm/mote_arm/bus.py b/mote_arm/mote_arm/bus.py index 4df4747..297b66e 100644 --- a/mote_arm/mote_arm/bus.py +++ b/mote_arm/mote_arm/bus.py @@ -33,6 +33,13 @@ # in the 0-4095 encoder frame. This is what stops a joint's travel straddling # the 0/4095 wrap; see mote_arm/calibrate.py. _HOMING_OFFSET = 31 +# SMS_STS_MIN_ANGLE_LIMIT_L / _MAX_ANGLE_LIMIT_L, both EEPROM. In position mode +# the servo refuses a goal outside this band, silently and in one direction — +# which looks exactly like a joint that has run out of torque. Nothing else in +# this repo reads or writes them, so a servo that arrived with a restricted +# range, or was configured with one, is invisible to every tool we have. +_MIN_ANGLE_LIMIT = 9 +_MAX_ANGLE_LIMIT = 11 _PRESENT_POSITION = 56 _PRESENT_LOAD = 60 _PRESENT_VOLTAGE = 62 @@ -346,6 +353,28 @@ def read_gains(self, servo_id: int) -> tuple[int, int, int] | None: time.sleep(0.1) return None + def read_angle_limits(self, servo_id: int) -> tuple[int, int] | None: + """Return (min, max) goal counts the servo will accept, or None. + + Read twice and trusted only when both agree, for the same reason + ``read_gains`` does: these live in EEPROM and a single read on this bus + has been seen to come back garbled. + """ + for _ in range(5): + first = ( + self._read(2, servo_id, _MIN_ANGLE_LIMIT), + self._read(2, servo_id, _MAX_ANGLE_LIMIT), + ) + time.sleep(0.05) + second = ( + self._read(2, servo_id, _MIN_ANGLE_LIMIT), + self._read(2, servo_id, _MAX_ANGLE_LIMIT), + ) + if None not in first and first == second: + return first # type: ignore[return-value] + time.sleep(0.1) + return None + def _read_gain_reg(self, servo_id: int, addr: int) -> int | None: return self._read(1, servo_id, addr) From 8a4ae2b9af7ea33ab882898561054a1746ef9bba Mon Sep 17 00:00:00 2001 From: Michael Johnson Date: Thu, 3 Sep 2026 00:01:36 +0100 Subject: [PATCH 09/22] The arm stops where the servo's own goal-range fence is, and now says so `shoulder_lift` stopped dead at -0.865 rad against a configured -1.7785, at 0% load, with the commanded position running 0.8 rad past it. It was not the teleop path: `arm-jog`, one trajectory per keypress, hit the same wall. EEPROM registers 9 and 11 (`Min_Angle_Limit`/`Max_Angle_Limit`) fence which goal positions a servo will accept and refuse the rest in silence -- no error, no status bit, no log line. Read off mote-01's arm: joint id min max accepts (rad) configured shoulder_pan 1 1123 3079 -1.419 .. +1.582 -2.033 .. +2.033 shoulder_lift 2 1478 3859 -0.874 .. +2.778 -1.778 .. +1.778 elbow_flex 3 716 2941 -2.043 .. +1.370 -1.646 .. +1.646 wrist_flex 4 709 3075 -2.054 .. +1.575 -1.761 .. +1.761 wrist_roll 5 0 4095 -3.142 .. +3.140 -2.880 .. +2.880 gripper 6 2047 3510 -0.002 .. +2.243 -1.073 .. +1.073 1478 counts is -0.874 rad about a zero of 2048, which is where the joint stopped. Five of six joints are fenced inside their own travel, which is the rest of "not going its full range". This retracts e741c44's root cause. That commit blamed the 12-bit goal register saturating at 0, which requires a zero 564 counts up; every joint on this arm is centred on 2048, so nothing there was ever clamped. Its two guards stand -- the mirror's stall clamp is what made this cap legible in the first place -- but they did not explain it. Two properties hid the fence. It binds only under torque, so `arm-calibrate` sweeps a limp joint straight through it and measures travel the arm afterwards refuses to make -- the calibration and the arm disagree and only the arm is wrong. And the band is compared against the corrected goal, so moving a zero moves what it fences without changing a number anyone can read. (Measured: the cap appeared at -0.865, not at the +0.824 a raw-frame fence would give.) So `arm-calibrate` clears the fence in phase 2 before it writes an offset, and `pixi run arm-limits show|clear|restore` is the standalone path. The as-found bands go to `$MOTE_HOME/arm_limits_backup.yaml` before the first write: like the offset register, they exist nowhere else, and they are the only record of how these servos shipped. `arm-check` reports the band beside the configured one. Cleared, not narrowed to match. The guard is the soft limit in `$MOTE_HOME/arm.yaml`, enforced by `MoteHardware::clamp_rad` and by `teleop.py`, where it is versioned and printed by three commands; a second copy in EEPROM adds nothing until the two disagree, and then it wins invisibly. Hence no `arm-limits set` -- a narrower envelope belongs in arm.yaml. 238 tests pass. Nothing was written to the arm: the diagnosis is a register read, and clearing the fence is a persistent hardware change for an operator to make with the arm in front of them. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_017hfWyNtkVh16hyvCK8K9cd --- CLAUDE.md | 23 +++ mote_arm/BENCH.md | 19 +++ mote_arm/README.md | 48 ++++++ mote_arm/TELEOP.md | 9 + mote_arm/mote_arm/arm_calibrate.py | 74 ++++++++- mote_arm/mote_arm/arm_check.py | 7 +- mote_arm/mote_arm/arm_limits.py | 219 +++++++++++++++++++++++++ mote_arm/mote_arm/bus.py | 28 ++++ mote_arm/mote_arm/calibrate.py | 46 ++++++ mote_arm/setup.py | 1 + mote_arm/test/test_arm_limits.py | 133 +++++++++++++++ mote_arm/test/test_calibrate_fences.py | 109 ++++++++++++ pixi.toml | 4 + 13 files changed, 716 insertions(+), 4 deletions(-) create mode 100644 mote_arm/mote_arm/arm_limits.py create mode 100644 mote_arm/test/test_arm_limits.py create mode 100644 mote_arm/test/test_calibrate_fences.py diff --git a/CLAUDE.md b/CLAUDE.md index dc29b02..cdd0144 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -26,6 +26,7 @@ pixi run arm # SO-101 arm: bench control stack (ros2_control, no miss pixi run arm-jog # Interactive per-joint jog CLI (needs a stack owning the bus) pixi run arm-check # Standalone arm bus enumeration + health (read-only, base stopped) pixi run arm-calibrate # Range calibration: centre the joints, sweep, emit limits +pixi run arm-limits # Servo goal-range fence (EEPROM 9/11): show / clear / restore pixi run arm-pose # Teach/replay named arm poses; narrow the envelope pixi run arm-teleop # Virtual-leader teleop: keyboard -> leader pose (mote_arm/TELEOP.md) pixi run arm-mirror # Mirror: leader pose -> clamped, rate-limited arm_controller goals @@ -819,6 +820,28 @@ section. Contains: way back. **Servos can arrive with non-zero offsets** (this arm: 2027, -1723, 1772, -1706, -40, 1317), so the existing value is always read and folded in. +- `arm_limits` (`pixi run arm-limits show|clear|restore`) — **a fourth place a + limit can live, and the only one not in a file.** EEPROM registers 9 and 11 + (`Min_Angle_Limit`/`Max_Angle_Limit`) fence which goals a servo accepts and + refuse the rest **in silence**: no error, no status bit, no log line, so the + joint stops at the same angle every time, in one direction, at any load — + indistinguishable from running out of torque. This arm arrived with five of + six joints fenced *inside their own travel*, and it presented as teleop being + "stuttery and not going its full range": `shoulder_lift` stopped at -0.865 rad + against a configured -1.7785, at 0% load, with the command running 0.8 rad + past it, and its `Min_Angle_Limit` read 1478 = -0.874 rad about zero 2048. + Two properties hid it. The fence binds **only under torque**, so + `arm-calibrate` sweeps a limp joint straight through it and measures travel + the arm will then refuse — the calibration and the arm disagree and only the + arm is wrong. And the band is compared against the **corrected** goal, so + moving a zero moves what it fences without changing any number a person can + read. `arm-calibrate` therefore clears the fence in phase 2 *before* it writes + an offset, snapshotting the as-found bands to `~/.mote/arm_limits_backup.yaml` + first; `arm-check` reports the band beside the configured one. **Cleared, not + narrowed to match**: the guard is the soft limit in `$MOTE_HOME/arm.yaml`, + enforced by `MoteHardware::clamp_rad` and `teleop.py`, which is versioned and + printed by three commands — a second copy in EEPROM adds nothing until the two + disagree, and then it wins invisibly. Hence no `arm-limits set`. - **Reads on this bus are hazardous twice over, and `FeetechBus._read` is the single choke point for both.** It clears the input buffer before every read, because a late reply is otherwise consumed as the answer to the *next* diff --git a/mote_arm/BENCH.md b/mote_arm/BENCH.md index a49f9c0..ac57ba5 100644 --- a/mote_arm/BENCH.md +++ b/mote_arm/BENCH.md @@ -182,6 +182,25 @@ its line, so pasting the block never silently reverts a joint to a guess. A join whose sweep is unusable also does not get its zero moved — the usable set is decided before any EEPROM is touched. +### The goal-range fence + +Between phase 1 and phase 2 the run reads registers 9 and 11 on every joint — +the band of goal positions the servo will accept — and offers to clear any that +is narrower than the whole 0-4095 range. Say yes. A fence binds only under +torque, so the sweep you just did went straight through it: the calibration +about to be written describes travel the arm will then refuse to make, silently, +stopping at the same angle every time as if it had run out of torque. This arm +arrived with five of six joints fenced. + +The as-found bands are snapshotted to `~/.mote/arm_limits_backup.yaml` before +the first write, so: + +``` +pixi run arm-limits show # read-only: the band, in counts and radians +pixi run arm-limits clear # hand the whole range back, outside a calibration +pixi run arm-limits restore # put the as-found bands back +``` + ### The offsets themselves ``` diff --git a/mote_arm/README.md b/mote_arm/README.md index 68c51e9..e3dce51 100644 --- a/mote_arm/README.md +++ b/mote_arm/README.md @@ -357,6 +357,54 @@ unless a driver was killed outright, which is detected by reading the torque register), and the result is saved without asking, since saving it is what the command is for. +### The servo's own goal-range limits, which are not the soft limits + +There is a fourth place a limit can live, and it is the only one that is not in +a file. EEPROM registers 9 and 11 (`Min_Angle_Limit` / `Max_Angle_Limit`) fence +which goal positions a servo will accept. A goal outside the band is **refused +in silence**: no error, no status bit, no log line. The joint stops at the same +angle every time, in one direction only, whatever the load — which is precisely +what running out of torque looks like. + +This arm arrived with five of six joints fenced inside their own travel, and it +cost an evening. `shoulder_lift` stopped dead at −0.865 rad against a +configured −1.7785, at 0% load, while the commanded position ran 0.8 rad past +it. Its `Min_Angle_Limit` read **1478**, and 1478 counts is −0.874 rad about a +zero of 2048: + +``` +joint id min max accepts (rad) configured +shoulder_pan 1 1123 3079 -1.419 .. +1.582 -2.033 .. +2.033 +shoulder_lift 2 1478 3859 -0.874 .. +2.778 -1.778 .. +1.778 +elbow_flex 3 716 2941 -2.043 .. +1.370 -1.646 .. +1.646 +wrist_flex 4 709 3075 -2.054 .. +1.575 -1.761 .. +1.761 +wrist_roll 5 0 4095 -3.142 .. +3.140 -2.880 .. +2.880 +gripper 6 2047 3510 -0.002 .. +2.243 -1.073 .. +1.073 +``` + +Two properties made it hard to see. The fence only binds under torque, so +`arm-calibrate` sweeps straight through it by hand and measures the full travel +— the calibration and the arm disagree, and only the arm is wrong. And the band +is compared against the *corrected* goal, so re-centring a zero moves what it +fences without changing a number anyone can read. + +``` +pixi run arm-limits show # read-only: the band, in counts and radians +pixi run arm-limits clear # hand every joint its whole 0-4095 range back +pixi run arm-limits restore # write the as-found bands back +``` + +`arm-calibrate` now clears them as part of phase 2, before it writes an offset, +and backs the as-found values up to `$MOTE_HOME/arm_limits_backup.yaml` first — +they exist nowhere else. `arm-check` reports the band beside the configured one. + +**Cleared, not narrowed to match.** The guard that matters is the soft limit in +`$MOTE_HOME/arm.yaml`, enforced by `MoteHardware::clamp_rad` and by `teleop.py`: +it is versioned, testable, and printed by three commands. A second copy in +EEPROM adds nothing until the day the two disagree, and then it wins invisibly. +So there is no `arm-limits set`; a narrower envelope belongs in `arm.yaml`, +where `arm-pose limits` already puts one. + ### Named poses, and narrowing the envelope The base layer captures a map position by driving there and running diff --git a/mote_arm/TELEOP.md b/mote_arm/TELEOP.md index c1a3172..ad6232d 100644 --- a/mote_arm/TELEOP.md +++ b/mote_arm/TELEOP.md @@ -117,6 +117,15 @@ chase after you have stopped. `--speed` (default 0.25 rad/s) sets how fast the leader moves; keep it at or below the mirror's `max_velocity` or the follower is permanently behind. +**If a joint stops short and stays there**, the live line marks it +`NOT FOLLOWING` and `arm-mirror --ros-args -p diagnose:=true` prints the +commanded and measured rates side by side. A command that keeps moving at +0.25 rad/s while the arm sits at 0.00 rad/s, at any load, is not the mirror and +not the deadman: check the servo's own goal-range fence with +`pixi run arm-limits show` (base stopped). It refuses goals outside its band in +silence, and reads exactly like a joint out of torque. See +[README](README.md#the-servos-own-goal-range-limits-which-are-not-the-soft-limits). + ### 3. Record ```bash diff --git a/mote_arm/mote_arm/arm_calibrate.py b/mote_arm/mote_arm/arm_calibrate.py index 7e75616..b631584 100644 --- a/mote_arm/mote_arm/arm_calibrate.py +++ b/mote_arm/mote_arm/arm_calibrate.py @@ -55,7 +55,7 @@ from mote_arm import config, poses -from mote_arm.bus import BusError, FeetechBus, port_holders +from mote_arm.bus import COUNTS_PER_TURN, BusError, FeetechBus, port_holders from mote_arm.calibrate import ( DEFAULT_MARGIN, CalibrationError, @@ -68,11 +68,15 @@ limits_from_sweep, pose_impact, save_calibration, + save_limits_backup, save_offsets_backup, zero_shift, ) from mote_arm.config import RAD_PER_COUNT +# The whole single-turn goal range, which a centred zero assumes it has. +FULL_RANGE = (0, COUNTS_PER_TURN - 1) + def _open_bus(cfg) -> FeetechBus: holders = port_holders(cfg.port) @@ -145,6 +149,72 @@ def final(self, rows: list[str]) -> None: print(line) +def _clear_fences(bus, joints, recorders, args) -> None: + """Hand every joint the whole 0-4095 goal range back before moving its zero. + + Registers 9 and 11 fence which goals a servo will accept, and a goal outside + the band is refused in silence: the joint stops at one angle, in one + direction, at any load. This arm arrived with five of six joints fenced + inside their own travel, and it read as the shoulder running out of torque. + + Cleared here for two reasons. The band is compared against the *corrected* + goal, so re-centring a zero moves what it fences without changing a number + anyone can see. And the limits this run is about to emit come from travel + swept by hand with torque off, where a fence stops nothing -- so a fence + left in place would refuse goals inside the very range being written to + arm.yaml. The soft limits stay the guard; they are in a file. + """ + bands = {} + for joint in joints: + band = bus.read_angle_limits(joint.id) + if band is None: + raise SystemExit( + f"could not read {joint.name}'s goal-range limits. Refusing to " + "continue blind -- a fence left in place silently caps the arm " + "inside the range this run is about to write." + ) + bands[joint.name] = band + + fenced = {n: b for n, b in bands.items() if b != FULL_RANGE} + if not fenced: + return + + print("\n=== the servos' own goal-range limits ===") + print("These fence which goals a servo accepts and refuse the rest in") + print("silence. Clearing them leaves arm.yaml's soft limits as the guard.") + print(f"\n{'joint':<16}{'accepts':>13}{'counts swept':>14}{'refused':>9}") + for joint in joints: + if joint.name not in fenced: + continue + low, high = fenced[joint.name] + swept = recorders[joint.name].result().unwrapped_span + print( + f"{joint.name:<16}{f'{low}..{high}':>13}{swept:>14}" + f"{max(0, swept - (high - low)):>9}" + ) + print("\n'refused' is how many counts of measured travel the servo will not go to.") + + if not _confirm(f"\nclear {len(fenced)} fence(s)? [y/N] ", args.yes): + raise SystemExit("aborted; nothing written") + + backup = save_limits_backup( + bands, + {j.name: j.id for j in joints}, + datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%SZ"), + ) + print(f"backed up to {backup} (`pixi run arm-limits restore` undoes this)") + + for name in sorted(fenced): + joint = next(j for j in joints if j.name == name) + if not bus.write_angle_limits(joint.id, *FULL_RANGE): + raise SystemExit( + f"\nSTOPPED: {name}: goal-range write not verified.\n" + f"Put the arm back with `pixi run arm-limits restore` " + f"(from {backup}), then investigate before re-running." + ) + print(f"{len(fenced)} fence(s) cleared and confirmed.") + + def _phase_centre(bus, joints, recorders, args) -> dict[str, int]: """Move each joint's zero to the middle of the range just swept. @@ -489,6 +559,8 @@ def _run(bus, cfg, selected, args) -> None: if not usable: raise SystemExit("\nno joint produced a usable sweep — nothing to emit") + _clear_fences(bus, usable, recorders, args) + offsets: dict[str, int] = {} calibrated: dict = {} if args.skip_homing: diff --git a/mote_arm/mote_arm/arm_check.py b/mote_arm/mote_arm/arm_check.py index d215d97..7ae60a6 100644 --- a/mote_arm/mote_arm/arm_check.py +++ b/mote_arm/mote_arm/arm_check.py @@ -59,9 +59,10 @@ def _report_angle_limits(limits: list) -> None: ) if problems: print( - f"\n{', '.join(problems)}: the servo will refuse goals outside its own " - "band, so the joint stops there whatever robot.yaml and arm.yaml say. " - "Widen the register or narrow the configured limits to match." + f"\n{', '.join(problems)}: the servo refuses goals outside its own " + "band, silently, so the joint stops there whatever robot.yaml and " + "arm.yaml say. `pixi run arm-limits clear` hands the whole range " + "back; `pixi run arm-limits show` says what is there now." ) diff --git a/mote_arm/mote_arm/arm_limits.py b/mote_arm/mote_arm/arm_limits.py new file mode 100644 index 0000000..3b1cdc4 --- /dev/null +++ b/mote_arm/mote_arm/arm_limits.py @@ -0,0 +1,219 @@ +"""Read, clear and restore the servos' goal-range registers. + +Registers 9 and 11 (``Min_Angle_Limit`` / ``Max_Angle_Limit``, EEPROM) fence +which goal positions a servo will accept. A goal outside the band is refused +**silently**: the joint stops at the same angle every time, in one direction +only, at any load — which reads exactly like running out of torque, and appears +in no config file, no URDF and no log. + + pixi run arm-limits show # read-only: the band, in counts and radians + pixi run arm-limits clear # hand every joint its whole 0-4095 range back + pixi run arm-limits restore # write the as-found bands back + +``clear`` is the normal state for this arm. The soft limits that matter are in +``$MOTE_HOME/arm.yaml``, enforced by ``MoteHardware`` and by ``teleop.py``, +where they are visible and versioned; a second band hidden in EEPROM only +duplicates them until the day the zero moves and the two disagree. There is +deliberately no ``set``: a narrower envelope belongs in ``arm.yaml``. + +Opens the bus directly, so run it with the driver stopped (`pixi run kill`). +``show`` never writes; ``clear`` and ``restore`` write EEPROM and ask first. +""" + +from __future__ import annotations + +import argparse +from datetime import datetime, timezone + +from mote_arm import config +from mote_arm.bus import COUNTS_PER_TURN, BusError, FeetechBus, port_holders +from mote_arm.calibrate import ( + limits_backup_path, + load_limits_backup, + save_limits_backup, +) + +FULL_RANGE = (0, COUNTS_PER_TURN - 1) + + +def _open_bus(cfg) -> FeetechBus: + holders = port_holders(cfg.port) + if holders: + for pid, cmd in holders: + print(f" port held by pid {pid}: {cmd}") + raise SystemExit( + "refusing to share the bus — stop the arm driver / robot base first " + "(`pixi run kill`)." + ) + bus = FeetechBus(cfg.port, cfg.baud_rate) + try: + bus.open() + except BusError as exc: + raise SystemExit(f"cannot open bus: {exc}") + return bus + + +def cuts(joint, band: tuple[int, int]) -> bool: + """True if the servo's band refuses part of the joint's configured range.""" + low, high = sorted((joint.counts_to_rad(band[0]), joint.counts_to_rad(band[1]))) + return low > joint.min_rad + 1e-6 or high < joint.max_rad - 1e-6 + + +def _read_all(cfg, bus) -> dict[str, tuple[int, int] | None]: + return {j.name: bus.read_angle_limits(j.id) for j in cfg.joints} + + +def _print_table(cfg, bus) -> dict[str, tuple[int, int] | None]: + bands = _read_all(cfg, bus) + print( + f"\n{'joint':<16}{'id':>3}{'min':>7}{'max':>7}" + f" {'accepts (rad)':<20}{'configured':<20}" + ) + for joint in cfg.joints: + band = bands[joint.name] + if band is None: + print(f"{joint.name:<16}{joint.id:>3}{'unreadable':>14}") + continue + # counts_to_rad honours `invert`, so an inverted joint's low count is + # its high angle; order them by angle, not by register. + low, high = sorted((joint.counts_to_rad(band[0]), joint.counts_to_rad(band[1]))) + note = " <-- CUTS THE CONFIGURED RANGE" if cuts(joint, band) else "" + print( + f"{joint.name:<16}{joint.id:>3}{band[0]:>7}{band[1]:>7} " + f"{f'[{low:+.3f}, {high:+.3f}]':<20}" + f"{f'[{joint.min_rad:+.3f}, {joint.max_rad:+.3f}]':<20}{note}" + ) + return bands + + +def _cmd_show(cfg, bus, args) -> None: + bands = _print_table(cfg, bus) + fenced = [j.name for j in cfg.joints if bands[j.name] and cuts(j, bands[j.name])] + if fenced: + print( + f"\n{', '.join(fenced)} stop short of their configured range and say " + "nothing about it. `pixi run arm-limits clear` hands the whole " + "0-4095 range back; the soft limits in arm.yaml still apply." + ) + backup = load_limits_backup() + if backup: + print(f"\na backup exists at {limits_backup_path()}:") + for name, (low, high) in sorted(backup.items()): + print(f" {name:<16}{low:>7}{high:>7}") + + +def _write(bus, cfg, wanted: dict[str, tuple[int, int]], args) -> None: + print(f"\nwill write {len(wanted)} band(s):") + for name, (low, high) in sorted(wanted.items()): + print(f" {name:<16}{low:>7}{high:>7}") + print("this writes servo EEPROM — a persistent hardware-config change.") + if not args.yes and input("proceed? [y/N] ").strip().lower() not in ("y", "yes"): + print("aborted; nothing written") + return + + failures = [] + for name, (low, high) in sorted(wanted.items()): + joint = cfg.joint(name) + ok = bus.write_angle_limits(joint.id, low, high) + print(f" {name:<16} {'written and verified' if ok else 'FAILED'}") + if not ok: + failures.append(name) + _print_table(cfg, bus) + if failures: + raise SystemExit( + f"could not verify the band on: {failures}. " + f"`pixi run arm-limits restore` puts the as-found values back." + ) + + +def _backup_once(cfg, bus, bands) -> None: + """Snapshot the as-found bands before the first write, never after. + + Written once and then left alone: a second snapshot taken after a `clear` + would record 0-4095 over the values it exists to preserve. + """ + if limits_backup_path().exists(): + return + missing = [n for n, v in bands.items() if v is None] + if missing: + raise SystemExit( + f"could not read the band on {missing} — refusing to write a " + "partial backup, since restoring from it would be wrong." + ) + path = save_limits_backup( + {n: v for n, v in bands.items() if v is not None}, + {j.name: j.id for j in cfg.joints}, + datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%SZ"), + ) + print(f"\nbacked the as-found bands up to {path}") + + +def _cmd_clear(cfg, bus, args) -> None: + bands = _print_table(cfg, bus) + selected = [j for j in cfg.joints if not args.joint or j.name == args.joint] + if args.joint and not selected: + raise SystemExit(f"unknown joint {args.joint!r}; have {cfg.names}") + wanted = { + j.name: FULL_RANGE for j in selected if bands[j.name] not in (FULL_RANGE, None) + } + if not wanted: + print("\nevery joint already accepts its whole range — nothing to write.") + return + _backup_once(cfg, bus, bands) + _write(bus, cfg, wanted, args) + + +def _cmd_restore(cfg, bus, args) -> None: + backup = load_limits_backup() + if not backup: + raise SystemExit( + f"no backup at {limits_backup_path()} — nothing to restore from." + ) + current = _print_table(cfg, bus) + wanted = { + name: band + for name, band in backup.items() + if name in cfg.names and current.get(name) != band + } + if not wanted: + print("\nevery servo already matches the backup — nothing to write.") + return + _write(bus, cfg, wanted, args) + + +def main() -> None: + parser = argparse.ArgumentParser( + description="Servo goal-range limits (EEPROM registers 9 and 11)" + ) + parser.add_argument("--robot-yaml", default="", help="override robot.yaml path") + parser.add_argument("--yes", action="store_true", help="skip confirmation") + sub = parser.add_subparsers(dest="cmd", required=True) + + sub.add_parser("show", help="read the bands (read-only)").set_defaults( + func=_cmd_show + ) + p_clear = sub.add_parser("clear", help="accept the whole 0-4095 range") + p_clear.add_argument("--joint", default="", help="one joint (default: all)") + p_clear.set_defaults(func=_cmd_clear) + sub.add_parser("restore", help="write the as-found bands back").set_defaults( + func=_cmd_restore + ) + + args = parser.parse_args() + cfg = ( + config.ArmConfig.from_yaml_file(args.robot_yaml) + if args.robot_yaml + else config.load() + ) + bus = _open_bus(cfg) + try: + args.func(cfg, bus, args) + finally: + bus.close() + + +if __name__ == "__main__": + main() + + +__all__ = ["main", "cuts", "FULL_RANGE"] diff --git a/mote_arm/mote_arm/bus.py b/mote_arm/mote_arm/bus.py index 297b66e..d6f4470 100644 --- a/mote_arm/mote_arm/bus.py +++ b/mote_arm/mote_arm/bus.py @@ -375,6 +375,34 @@ def read_angle_limits(self, servo_id: int) -> tuple[int, int] | None: time.sleep(0.1) return None + def write_angle_limits(self, servo_id: int, low: int, high: int) -> bool: + """Write the goal-range registers to EEPROM and verify they took. + + ``low``/``high`` are raw counts in the same frame as a goal position, so + 0 and 4095 hand the joint its whole single-turn range back. Returns True + only once a confirmed read-back matches, for the reason + ``write_homing_offset`` does: this is persistent servo config with no + copy anywhere else, and reporting an unverified write would leave the + arm silently capped. + """ + if not 0 <= low <= high <= COUNTS_PER_TURN - 1: + raise ValueError( + f"angle limits {low}..{high} outside 0..{COUNTS_PER_TURN - 1}" + ) + for _ in range(4): + self._packet.write1ByteTxRx(self._port, servo_id, _LOCK, 0) + time.sleep(0.05) + self._packet.write2ByteTxRx(self._port, servo_id, _MIN_ANGLE_LIMIT, low) + time.sleep(0.05) + self._packet.write2ByteTxRx(self._port, servo_id, _MAX_ANGLE_LIMIT, high) + time.sleep(0.05) + self._packet.write1ByteTxRx(self._port, servo_id, _LOCK, 1) + # The read-back races the relock; give the servo time to settle. + time.sleep(0.15) + if self.read_angle_limits(servo_id) == (low, high): + return True + return False + def _read_gain_reg(self, servo_id: int, addr: int) -> int | None: return self._read(1, servo_id, addr) diff --git a/mote_arm/mote_arm/calibrate.py b/mote_arm/mote_arm/calibrate.py index 235388c..26f99b3 100644 --- a/mote_arm/mote_arm/calibrate.py +++ b/mote_arm/mote_arm/calibrate.py @@ -588,3 +588,49 @@ def load_offsets_backup(path: Path | str | None = None) -> dict[str, int]: str(name): int(entry["offset"]) for name, entry in (data.get("offsets") or {}).items() } + + +def limits_backup_path() -> Path: + return mote_home() / "arm_limits_backup.yaml" + + +def save_limits_backup( + limits: dict[str, tuple[int, int]], + ids: dict[str, int], + when: str, + path: Path | str | None = None, +) -> Path: + """Record the goal-range registers as found, *before* any are overwritten. + + Same rule as ``save_offsets_backup``, for the same reason: registers 9 and + 11 live only in the servo. This arm arrived with five of six joints fenced + to a band narrower than their travel, which nothing here had ever read, so + the value being overwritten may be the only record of how a servo shipped. + """ + p = Path(path) if path is not None else limits_backup_path() + p.parent.mkdir(parents=True, exist_ok=True) + p.write_text( + yaml.safe_dump( + { + "saved": when, + "limits": { + name: {"id": ids[name], "min": int(low), "max": int(high)} + for name, (low, high) in sorted(limits.items()) + }, + }, + sort_keys=True, + ) + ) + return p + + +def load_limits_backup(path: Path | str | None = None) -> dict[str, tuple[int, int]]: + """Return {joint: (min, max)} from the backup, or empty if there is none.""" + p = Path(path) if path is not None else limits_backup_path() + if not p.exists(): + return {} + data = yaml.safe_load(p.read_text()) or {} + return { + str(name): (int(entry["min"]), int(entry["max"])) + for name, entry in (data.get("limits") or {}).items() + } diff --git a/mote_arm/setup.py b/mote_arm/setup.py index aa8cb35..f6e9006 100644 --- a/mote_arm/setup.py +++ b/mote_arm/setup.py @@ -24,6 +24,7 @@ "arm_check = mote_arm.arm_check:main", "arm_calibrate = mote_arm.arm_calibrate:main", "arm_offsets = mote_arm.arm_offsets:main", + "arm_limits = mote_arm.arm_limits:main", "arm_pose = mote_arm.arm_pose:main", "arm_gains = mote_arm.arm_gains:main", "virtual_leader = mote_arm.virtual_leader:main", diff --git a/mote_arm/test/test_arm_limits.py b/mote_arm/test/test_arm_limits.py new file mode 100644 index 0000000..e4af45a --- /dev/null +++ b/mote_arm/test/test_arm_limits.py @@ -0,0 +1,133 @@ +"""The servos' goal-range registers: reading, writing, and the backup pair. + +These registers fence which goals a servo will accept and refuse the rest with +no error, no log line and no field in any config file. The arm this package was +written against arrived with five of six joints fenced inside their own travel, +which presented as the shoulder running out of torque. So the properties worth +holding are: a write is only reported once a read-back confirms it, an +out-of-range band is refused rather than truncated, and the as-found values are +recoverable after they have been overwritten. +""" + +import pytest + +from mote_arm import bus as bus_mod +from mote_arm.arm_limits import FULL_RANGE, cuts +from mote_arm.bus import FeetechBus +from mote_arm.calibrate import load_limits_backup, save_limits_backup +from mote_arm.config import JointSpec + +COMM_OK = 0 +COMM_FAIL = -1 + + +class StubServo: + """A packet handler backed by a dict of 2-byte registers.""" + + def __init__(self, registers=None, writes_take=True): + self.registers = dict(registers or {}) + self.writes = [] + self.writes_take = writes_take + + def read1ByteTxRx(self, _port, servo_id, addr): + return self.registers.get((servo_id, addr), 0), COMM_OK, 0 + + def write1ByteTxRx(self, _port, servo_id, addr, value): + self.writes.append((servo_id, addr, value)) + return COMM_OK, 0 + + def read2ByteTxRx(self, _port, servo_id, addr): + return self.registers.get((servo_id, addr), 0), COMM_OK, 0 + + def write2ByteTxRx(self, _port, servo_id, addr, value): + self.writes.append((servo_id, addr, value)) + if self.writes_take: + self.registers[(servo_id, addr)] = value + return COMM_OK, 0 + + +def make_bus(packet): + bus = FeetechBus("/dev/fake", 1000000) + bus._packet = packet + bus._port = object() + bus._comm_success = COMM_OK + return bus + + +def limits_of(packet, servo_id): + return ( + packet.registers.get((servo_id, bus_mod._MIN_ANGLE_LIMIT)), + packet.registers.get((servo_id, bus_mod._MAX_ANGLE_LIMIT)), + ) + + +def fenced(low=1478, high=3859, servo_id=2): + return StubServo( + { + (servo_id, bus_mod._MIN_ANGLE_LIMIT): low, + (servo_id, bus_mod._MAX_ANGLE_LIMIT): high, + } + ) + + +def test_the_band_is_read_back_as_written(): + packet = fenced() + bus = make_bus(packet) + assert bus.read_angle_limits(2) == (1478, 3859) + assert bus.write_angle_limits(2, *FULL_RANGE) is True + assert limits_of(packet, 2) == FULL_RANGE + + +def test_a_write_that_does_not_take_is_reported_as_failed(): + """A silent failure here leaves the arm capped while the tool says done.""" + packet = fenced() + packet.writes_take = False + assert make_bus(packet).write_angle_limits(2, *FULL_RANGE) is False + assert limits_of(packet, 2) == (1478, 3859) + + +def test_an_unreadable_register_is_none_not_a_guess(): + class Deaf(StubServo): + def read2ByteTxRx(self, _port, servo_id, addr): + return 0, COMM_FAIL, 0 + + assert make_bus(Deaf()).read_angle_limits(2) is None + + +@pytest.mark.parametrize("band", [(-1, 4095), (0, 4096), (3000, 1000)]) +def test_a_band_outside_the_register_is_refused(band): + with pytest.raises(ValueError): + make_bus(fenced()).write_angle_limits(2, *band) + + +def joint(name="shoulder_lift", low=-1.7785, high=1.7785, zero=2048, invert=False): + return JointSpec( + name=name, id=2, min_rad=low, max_rad=high, zero_counts=zero, invert=invert + ) + + +def test_a_band_inside_the_configured_range_is_a_cut(): + # The register that started this: 1478 is -0.874 rad about a zero of 2048, + # and the joint is configured to reach -1.7785. + assert cuts(joint(), (1478, 3859)) is True + + +def test_the_whole_register_range_cuts_nothing(): + assert cuts(joint(), FULL_RANGE) is False + + +def test_an_inverted_joint_is_judged_by_angle_not_by_register(): + """counts_to_rad flips the sign, so the low count is the high angle.""" + assert cuts(joint(invert=True), (1478, 3859)) is True + assert cuts(joint(invert=True), FULL_RANGE) is False + + +def test_the_backup_round_trips(tmp_path): + path = tmp_path / "arm_limits_backup.yaml" + found = {"shoulder_lift": (1478, 3859), "wrist_roll": (0, 4095)} + save_limits_backup(found, {"shoulder_lift": 2, "wrist_roll": 5}, "now", path) + assert load_limits_backup(path) == found + + +def test_a_missing_backup_reads_as_empty_rather_than_raising(tmp_path): + assert load_limits_backup(tmp_path / "absent.yaml") == {} diff --git a/mote_arm/test/test_calibrate_fences.py b/mote_arm/test/test_calibrate_fences.py new file mode 100644 index 0000000..399c171 --- /dev/null +++ b/mote_arm/test/test_calibrate_fences.py @@ -0,0 +1,109 @@ +"""`arm-calibrate` clearing the servos' goal-range fence before it moves a zero. + +The fence binds only under torque, so phase 1 sweeps a limp joint straight +through it and measures travel the arm will afterwards refuse to make. Leaving +it in place therefore produces a calibration that describes a range the arm +stops short of, silently, at the same angle every time. The properties held +here: a fenced joint is cleared, an unfenced arm is left alone entirely, the +as-found bands are snapshotted before the first write, and a write that cannot +be verified stops the run rather than continuing into the offsets. +""" + +import pytest + +from mote_arm import arm_calibrate +from mote_arm.calibrate import Sweep, load_limits_backup +from mote_arm.config import JointSpec + +FULL = arm_calibrate.FULL_RANGE + + +class FakeBus: + def __init__(self, bands, writes_take=True): + self.bands = dict(bands) + self.writes_take = writes_take + self.written = [] + + def read_angle_limits(self, servo_id): + return self.bands.get(servo_id) + + def write_angle_limits(self, servo_id, low, high): + self.written.append((servo_id, low, high)) + if not self.writes_take: + return False + self.bands[servo_id] = (low, high) + return True + + +class FakeRecorder: + def __init__(self, span): + self._span = span + + def result(self): + return Sweep( + name="j", + samples=100, + min_counts=852, + max_counts=3236, + wraps=0, + unwrapped_min=852, + unwrapped_max=852 + self._span, + ) + + +class Args: + yes = True + + +def joints(): + return [ + JointSpec(name="shoulder_lift", id=2, min_rad=-1.7785, max_rad=1.7785), + JointSpec(name="wrist_roll", id=5, min_rad=-2.88, max_rad=2.88), + ] + + +def recorders(): + return {"shoulder_lift": FakeRecorder(2384), "wrist_roll": FakeRecorder(3820)} + + +def test_a_fenced_joint_is_cleared(tmp_path, monkeypatch, capsys): + monkeypatch.setenv("MOTE_HOME", str(tmp_path)) + bus = FakeBus({2: (1478, 3859), 5: FULL}) + arm_calibrate._clear_fences(bus, joints(), recorders(), Args()) + assert bus.written == [(2, 0, 4095)] + assert bus.bands[2] == FULL + # The one joint that already accepted its whole range is not rewritten. + assert "wrist_roll" not in capsys.readouterr().out.split("=== the servos")[1] + + +def test_the_as_found_bands_are_saved_before_the_first_write(tmp_path, monkeypatch): + monkeypatch.setenv("MOTE_HOME", str(tmp_path)) + bus = FakeBus({2: (1478, 3859), 5: FULL}) + arm_calibrate._clear_fences(bus, joints(), recorders(), Args()) + assert load_limits_backup() == {"shoulder_lift": (1478, 3859), "wrist_roll": FULL} + + +def test_an_unfenced_arm_writes_nothing_and_says_nothing(tmp_path, monkeypatch, capsys): + monkeypatch.setenv("MOTE_HOME", str(tmp_path)) + bus = FakeBus({2: FULL, 5: FULL}) + arm_calibrate._clear_fences(bus, joints(), recorders(), Args()) + assert bus.written == [] + assert capsys.readouterr().out == "" + + +def test_a_write_that_cannot_be_verified_stops_the_run(tmp_path, monkeypatch): + monkeypatch.setenv("MOTE_HOME", str(tmp_path)) + bus = FakeBus({2: (1478, 3859), 5: FULL}, writes_take=False) + with pytest.raises(SystemExit) as exc: + arm_calibrate._clear_fences(bus, joints(), recorders(), Args()) + assert "arm-limits restore" in str(exc.value) + + +def test_an_unreadable_band_stops_the_run_rather_than_assuming_it_is_open( + tmp_path, monkeypatch +): + monkeypatch.setenv("MOTE_HOME", str(tmp_path)) + bus = FakeBus({5: FULL}) # servo 2 does not answer + with pytest.raises(SystemExit) as exc: + arm_calibrate._clear_fences(bus, joints(), recorders(), Args()) + assert "shoulder_lift" in str(exc.value) diff --git a/pixi.toml b/pixi.toml index ebee3eb..43bcd76 100644 --- a/pixi.toml +++ b/pixi.toml @@ -160,6 +160,10 @@ arm-calibrate = "ros2 run mote_arm arm_calibrate" # Read/back up/restore the servos' position-correction offsets (EEPROM). The # recovery path if a calibration run is interrupted part-way. arm-offsets = "ros2 run mote_arm arm_offsets" +# Read/clear/restore the servos' goal-range limits (EEPROM registers 9 and 11). +# A goal outside the band is refused silently, so a fenced joint stops at the +# same angle every time and reads exactly like a joint out of torque. +arm-limits = "ros2 run mote_arm arm_limits" # Show/apply the arm servos' position-loop gains from robot.yaml (EEPROM). arm-gains = "ros2 run mote_arm arm_gains" # Virtual-leader teleop (mote_arm/TELEOP.md). No leader arm: the keyboard moves a From 4ce0b0e309d32c197a32b955768884cb4ee5e3ff Mon Sep 17 00:00:00 2001 From: Michael Johnson Date: Thu, 3 Sep 2026 09:56:39 +0100 Subject: [PATCH 10/22] Record what set the arm's goal-range fence A LeRobot calibration, before any of the Mote arm work, and the arithmetic says so rather than the guess doing. The fence spans match the travel `arm-calibrate` sweeps to within 1, 3, 5 and 14 counts on gripper, shoulder_lift, wrist_flex and elbow_flex -- a recorded range of motion of this arm, not a factory default. The shift between the fence and that sweep equals the change in each servo's homing offset since the arm arrived (626 vs 616, -226 vs -215, -157 vs -152, +740 vs +726), so it was recorded while the servos still carried the offsets they came with, i.e. before 2026-07-28. The two odd joints identify the tool, and both are already written up in this README as things LeRobot does. wrist_roll is the one unfenced joint, and it is the one joint LeRobot hard-codes as full-turn and skips. shoulder_pan's band is 760 counts short, which is what an unwrapped min/max `record_ranges_of_motion` yields for a joint whose travel crosses 0/4095 -- and shoulder_pan is one of the two that do. LeRobot also demonstrably writes these registers here: the cache at ~/.cache/huggingface/lerobot/ lists the drive wheels at 0-4095, and the wheels read 0-4095. Inferred, not measured: there is no arm entry in that cache, so the arm's run happened on another machine or before the arm shipped. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_017hfWyNtkVh16hyvCK8K9cd --- mote_arm/README.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/mote_arm/README.md b/mote_arm/README.md index e3dce51..0705eef 100644 --- a/mote_arm/README.md +++ b/mote_arm/README.md @@ -382,6 +382,22 @@ wrist_roll 5 0 4095 -3.142 .. +3.140 -2.880 .. +2.880 gripper 6 2047 3510 -0.002 .. +2.243 -1.073 .. +1.073 ``` +**What set it: a LeRobot calibration, before any of this.** The fence spans +match the travel `arm-calibrate` sweeps to within 1, 3, 5 and 14 counts on +`gripper`, `shoulder_lift`, `wrist_flex` and `elbow_flex` — so it is a recorded +range of motion of this arm, not a factory default. The shift between the fence +and that sweep equals the change in each servo's homing offset since the arm +arrived, within ~10 counts, so it was recorded while the servos still carried +the offsets they came with. The two odd joints identify the tool: `wrist_roll` +is unfenced, which is the one joint LeRobot hard-codes as full-turn and skips; +and `shoulder_pan`'s band is 760 counts short, which is what LeRobot's +unwrapped min/max `record_ranges_of_motion` produces for a joint whose travel +crosses 0/4095 — and `shoulder_pan` is one of the two that do. LeRobot also +demonstrably writes these registers on this robot: the calibration cached at +`~/.cache/huggingface/lerobot/` lists the two drive wheels at 0-4095, and the +wheels read 0-4095. There is no arm entry in that cache, so the arm's run +happened elsewhere — another machine, or the seller before shipping. + Two properties made it hard to see. The fence only binds under torque, so `arm-calibrate` sweeps straight through it by hand and measures the full travel — the calibration and the arm disagree, and only the arm is wrong. And the band From 05d599ca8916266ce67efbb67cb38a5afa1883cf Mon Sep 17 00:00:00 2001 From: Michael Johnson Date: Thu, 3 Sep 2026 10:08:47 +0100 Subject: [PATCH 11/22] Calibration writes the fence and the zero together, or neither Answers "should we be saving the limits as part of calibration too?" -- yes, and the LeRobot file found on the workstation is why. `so101_follower.json` (2026-05-12) holds exactly the six bands read out of the servos tonight, beside the homing offsets the arm arrived with. LeRobot wrote fence and offset in one run and they agreed. What broke is that `arm-calibrate` moved the offsets on 2026-07-28 and left the fence behind: a band is compared against the *corrected* goal, so it goes on refusing the same counts, which now name different angles. Five of six joints were capped for four months at 0% load. So the fix is not to leave the arm unfenced, it is to stop splitting the pair. Phase 2 now unfences every joint, moves the zeros, and fences each joint at the stops it just measured -- in that order, so a run that dies in between leaves an unfenced arm, which is recoverable, rather than one fenced in a frame nothing uses. Both as-found sets are snapshotted before the first write. One confirmation covers both, since phase 2 already had one. The band written is `calibrate.fence_counts`: the *measured travel*, not the soft limits -- wider by --margin at each end. That is what makes it a backstop rather than a second opinion. arm.yaml always binds first, so the fence can never be what stops the arm in ordinary use, and `arm-limits show` reporting a band narrower than the configured one now means something is wrong rather than meaning Tuesday. What it catches is a soft limit that has gone wrong: a hand-edited arm.yaml, a URDF that never received one, a servo swapped under a stale calibration. --skip-homing promises to write nothing to the servos, so it reports a cutting fence instead of correcting it. Two of LeRobot's six bands were wrong when written, which is worth knowing before trusting one: wrist_roll is unfenced because LeRobot hard-codes the SO-101's wrist_roll as full-turn and skips its range, and shoulder_pan's band is 760 counts short because an unwrapped min/max `record_ranges_of_motion` mis-records a joint whose sweep crosses 0/4095 -- which shoulder_pan does. 251 tests pass. test_calibrate_phase2.py drives the whole phase against a fake bus whose position reading follows its offset register, so an ordering mistake -- fencing before the zeros move, backing up after the first write -- fails there rather than on hardware. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_017hfWyNtkVh16hyvCK8K9cd --- CLAUDE.md | 32 +++- mote_arm/BENCH.md | 19 ++- mote_arm/README.md | 69 +++++--- mote_arm/mote_arm/arm_calibrate.py | 184 +++++++++++++++----- mote_arm/mote_arm/calibrate.py | 33 ++++ mote_arm/test/test_calibrate_fences.py | 228 ++++++++++++++++++------- mote_arm/test/test_calibrate_phase2.py | 185 ++++++++++++++++++++ 7 files changed, 605 insertions(+), 145 deletions(-) create mode 100644 mote_arm/test/test_calibrate_phase2.py diff --git a/CLAUDE.md b/CLAUDE.md index cdd0144..84c7d1f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -835,13 +835,31 @@ section. Contains: the arm will then refuse — the calibration and the arm disagree and only the arm is wrong. And the band is compared against the **corrected** goal, so moving a zero moves what it fences without changing any number a person can - read. `arm-calibrate` therefore clears the fence in phase 2 *before* it writes - an offset, snapshotting the as-found bands to `~/.mote/arm_limits_backup.yaml` - first; `arm-check` reports the band beside the configured one. **Cleared, not - narrowed to match**: the guard is the soft limit in `$MOTE_HOME/arm.yaml`, - enforced by `MoteHardware::clamp_rad` and `teleop.py`, which is versioned and - printed by three commands — a second copy in EEPROM adds nothing until the two - disagree, and then it wins invisibly. Hence no `arm-limits set`. + read. **What set it is known**: + `lerobot-calibrate` on the workstation, 2026-05-12, and the file is still there + (`~/.cache/huggingface/lerobot/calibration/robots/so_follower/so101_follower.json`, + whose `range_min`/`range_max` are those six bands to the count, beside the + `homing_offset` values the servos arrived with). Writing them was reasonable; + **what broke is that `arm-calibrate` then moved the zeros on 2026-07-28 and + left the fence behind.** Two of the six were wrong even when written — + `wrist_roll` unfenced because LeRobot hard-codes the SO-101's wrist_roll as + full-turn and skips it, and `shoulder_pan` 760 counts short because its + unwrapped min/max `record_ranges_of_motion` mis-records a wrap-crossing joint, + which shoulder_pan is. So **`arm-calibrate` now writes the fence and the zero + in one run and never one without the other**: unfence, move the zeros, fence + again at the stops just measured, both as-found sets snapshotted first + (`arm_offsets_backup.yaml`, `arm_limits_backup.yaml`), the intermediate state + deliberately *unfenced* so a run that dies between them is recoverable. + `--skip-homing` promises to touch no servo, so it reports a cutting fence + rather than correcting it. **The band written is the measured travel, not the + soft limits** (`calibrate.fence_counts`) — wider by `--margin` at each end, so + `arm.yaml` always binds first and the fence can never be what stops the arm in + ordinary use; `arm-limits show` reporting a band narrower than the configured + one therefore means something is wrong. What it backstops is a soft limit that + has gone wrong — a hand-edited arm.yaml, a URDF that never received one, a + servo swapped under a stale calibration. There is deliberately no + `arm-limits set`: a *narrower* envelope belongs in arm.yaml, where three + commands print it. `arm-check` reports the band beside the configured one. - **Reads on this bus are hazardous twice over, and `FeetechBus._read` is the single choke point for both.** It clears the input buffer before every read, because a late reply is otherwise consumed as the answer to the *next* diff --git a/mote_arm/BENCH.md b/mote_arm/BENCH.md index ac57ba5..15c158f 100644 --- a/mote_arm/BENCH.md +++ b/mote_arm/BENCH.md @@ -184,13 +184,18 @@ decided before any EEPROM is touched. ### The goal-range fence -Between phase 1 and phase 2 the run reads registers 9 and 11 on every joint — -the band of goal positions the servo will accept — and offers to clear any that -is narrower than the whole 0-4095 range. Say yes. A fence binds only under -torque, so the sweep you just did went straight through it: the calibration -about to be written describes travel the arm will then refuse to make, silently, -stopping at the same angle every time as if it had run out of torque. This arm -arrived with five of six joints fenced. +Phase 2 shows registers 9 and 11 on every joint — the band of goal positions +the servo will accept — under the same confirmation as the zeros, and then +rewrites both. A fence binds only under torque, so the sweep you just did went +straight through it: a fence left behind describes travel the arm will refuse to +make, silently, stopping at the same angle every time as if it had run out of +torque. This arm spent four months in exactly that state. + +The order is unfence, move the zeros, fence again at the stops just measured. +The new band is wider than the soft limits in `arm.yaml` by `--margin` at each +end, so the soft limits always stop the arm first and the fence only acts if +they have gone wrong. `--skip-homing` writes nothing to the servos, so it +reports a cutting fence rather than correcting it. The as-found bands are snapshotted to `~/.mote/arm_limits_backup.yaml` before the first write, so: diff --git a/mote_arm/README.md b/mote_arm/README.md index 0705eef..5465f10 100644 --- a/mote_arm/README.md +++ b/mote_arm/README.md @@ -382,21 +382,28 @@ wrist_roll 5 0 4095 -3.142 .. +3.140 -2.880 .. +2.880 gripper 6 2047 3510 -0.002 .. +2.243 -1.073 .. +1.073 ``` -**What set it: a LeRobot calibration, before any of this.** The fence spans -match the travel `arm-calibrate` sweeps to within 1, 3, 5 and 14 counts on -`gripper`, `shoulder_lift`, `wrist_flex` and `elbow_flex` — so it is a recorded -range of motion of this arm, not a factory default. The shift between the fence -and that sweep equals the change in each servo's homing offset since the arm -arrived, within ~10 counts, so it was recorded while the servos still carried -the offsets they came with. The two odd joints identify the tool: `wrist_roll` -is unfenced, which is the one joint LeRobot hard-codes as full-turn and skips; -and `shoulder_pan`'s band is 760 counts short, which is what LeRobot's -unwrapped min/max `record_ranges_of_motion` produces for a joint whose travel -crosses 0/4095 — and `shoulder_pan` is one of the two that do. LeRobot also -demonstrably writes these registers on this robot: the calibration cached at -`~/.cache/huggingface/lerobot/` lists the two drive wheels at 0-4095, and the -wheels read 0-4095. There is no arm entry in that cache, so the arm's run -happened elsewhere — another machine, or the seller before shipping. +**What set it: `lerobot-calibrate`, on the workstation, 2026-05-12.** The file +is still there — +`~/.cache/huggingface/lerobot/calibration/robots/so_follower/so101_follower.json` +— and its `range_min`/`range_max` are the six bands above to the count, beside +the `homing_offset` values the servos arrived with (2027, -1723, 1772, -1706, +-40, 1317). An earlier attempt sits next to it from the same morning +(`auldbot_arm.json`, 10:40) with `wrist_flex` still unswept at 0-4095. + +LeRobot writes those registers from the range of motion it records, which is a +reasonable thing to do and is not what broke. **What broke is that +`arm-calibrate` then moved the zeros and left the fence where it was**, on +2026-07-28: a band in the corrected frame names different physical angles once +the offset under it changes, so the fence silently drifted onto the middle of +the joint's travel. Hence this tool writes the fence *and* the offset in one +run, and never one without the other. + +Two of the six bands were already wrong when LeRobot wrote them, which is worth +knowing before trusting one. `wrist_roll` is unfenced because LeRobot hard-codes +the SO-101's `wrist_roll` as full-turn and skips its range. `shoulder_pan`'s +band is 760 counts narrower than its real travel, which is what an unwrapped +min/max `record_ranges_of_motion` yields for a joint whose sweep crosses 0/4095 +— and `shoulder_pan` is one of the two joints here that do. Two properties made it hard to see. The fence only binds under torque, so `arm-calibrate` sweeps straight through it by hand and measures the full travel @@ -410,16 +417,28 @@ pixi run arm-limits clear # hand every joint its whole 0-4095 range back pixi run arm-limits restore # write the as-found bands back ``` -`arm-calibrate` now clears them as part of phase 2, before it writes an offset, -and backs the as-found values up to `$MOTE_HOME/arm_limits_backup.yaml` first — -they exist nowhere else. `arm-check` reports the band beside the configured one. - -**Cleared, not narrowed to match.** The guard that matters is the soft limit in -`$MOTE_HOME/arm.yaml`, enforced by `MoteHardware::clamp_rad` and by `teleop.py`: -it is versioned, testable, and printed by three commands. A second copy in -EEPROM adds nothing until the day the two disagree, and then it wins invisibly. -So there is no `arm-limits set`; a narrower envelope belongs in `arm.yaml`, -where `arm-pose limits` already puts one. +**The fence and the zero are now written by one run, and never one without the +other.** Phase 2 unfences every joint, moves the zeros, and then fences each +joint at the stops it just measured — in that order, so a run that dies in +between leaves an unfenced arm, which is recoverable, rather than one fenced in +a frame nothing uses. Both as-found sets are snapshotted first +(`arm_offsets_backup.yaml`, `arm_limits_backup.yaml`); neither exists anywhere +else. `--skip-homing` promises to write nothing to the servos, so it reports a +cutting fence instead of correcting it. + +**The band written is the measured travel, not the soft limits** — wider by +`--margin` (0.05 rad) at each end. That is what makes it a backstop rather than +a second opinion: `arm.yaml` always binds first, so the fence can never be the +thing that stops the arm in ordinary use, and `arm-limits show` reporting a band +narrower than the configured one therefore means something is wrong rather than +meaning Tuesday. What it catches is a soft limit that has gone wrong — a +hand-edited `arm.yaml`, a URDF that never received one, a servo swapped under a +stale calibration — where the servo still refuses to drive past its own stops. + +There is no `arm-limits set`. A *narrower* envelope belongs in `arm.yaml`, where +`arm-pose limits` already puts one and where three commands print it; +`arm-limits clear` exists to take the fence off while diagnosing, and `restore` +to put back whatever was found. ### Named poses, and narrowing the envelope diff --git a/mote_arm/mote_arm/arm_calibrate.py b/mote_arm/mote_arm/arm_calibrate.py index b631584..b26916e 100644 --- a/mote_arm/mote_arm/arm_calibrate.py +++ b/mote_arm/mote_arm/arm_calibrate.py @@ -67,6 +67,8 @@ homing_offset, limits_from_sweep, pose_impact, + fence_counts, + limits_backup_path, save_calibration, save_limits_backup, save_offsets_backup, @@ -149,20 +151,13 @@ def final(self, rows: list[str]) -> None: print(line) -def _clear_fences(bus, joints, recorders, args) -> None: - """Hand every joint the whole 0-4095 goal range back before moving its zero. +def _read_fences(bus, joints) -> dict[str, tuple[int, int]]: + """Every joint's goal-range band, refusing to continue if one cannot be read. - Registers 9 and 11 fence which goals a servo will accept, and a goal outside - the band is refused in silence: the joint stops at one angle, in one - direction, at any load. This arm arrived with five of six joints fenced - inside their own travel, and it read as the shoulder running out of torque. - - Cleared here for two reasons. The band is compared against the *corrected* - goal, so re-centring a zero moves what it fences without changing a number - anyone can see. And the limits this run is about to emit come from travel - swept by hand with torque off, where a fence stops nothing -- so a fence - left in place would refuse goals inside the very range being written to - arm.yaml. The soft limits stay the guard; they are in a file. + Registers 9 and 11 fence which goals a servo will accept and refuse the rest + in silence: the joint stops at one angle, in one direction, at any load. + Continuing blind would emit soft limits without knowing whether the arm can + reach them, which is the state this arm spent four months in. """ bands = {} for joint in joints: @@ -174,15 +169,15 @@ def _clear_fences(bus, joints, recorders, args) -> None: "inside the range this run is about to write." ) bands[joint.name] = band + return bands + +def _show_fences(joints, bands, recorders) -> None: fenced = {n: b for n, b in bands.items() if b != FULL_RANGE} if not fenced: + print("\nNo joint is fenced today; one will be written from the sweep.") return - - print("\n=== the servos' own goal-range limits ===") - print("These fence which goals a servo accepts and refuse the rest in") - print("silence. Clearing them leaves arm.yaml's soft limits as the guard.") - print(f"\n{'joint':<16}{'accepts':>13}{'counts swept':>14}{'refused':>9}") + print(f"\n{'joint':<16}{'accepts now':>13}{'counts swept':>14}{'refused':>9}") for joint in joints: if joint.name not in fenced: continue @@ -192,40 +187,110 @@ def _clear_fences(bus, joints, recorders, args) -> None: f"{joint.name:<16}{f'{low}..{high}':>13}{swept:>14}" f"{max(0, swept - (high - low)):>9}" ) - print("\n'refused' is how many counts of measured travel the servo will not go to.") + print("'refused' is how many counts of measured travel the servo will not go to.") - if not _confirm(f"\nclear {len(fenced)} fence(s)? [y/N] ", args.yes): - raise SystemExit("aborted; nothing written") - backup = save_limits_backup( - bands, - {j.name: j.id for j in joints}, - datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%SZ"), - ) - print(f"backed up to {backup} (`pixi run arm-limits restore` undoes this)") +def _clear_fences(bus, joints, bands, backup) -> None: + """Unfence every joint before its zero moves. - for name in sorted(fenced): - joint = next(j for j in joints if j.name == name) + The band is compared against the *corrected* goal, so a fence outlives the + frame it was measured in: move the zero under one and it goes on refusing + the same counts, which are now different physical angles. That is exactly + how this arm broke -- LeRobot wrote fence and offset together in May, and a + later `arm-calibrate` moved the offsets and left the fence behind. Clearing + first means a run that dies between here and `_write_fences` leaves the arm + unfenced, which is recoverable, rather than fenced in a frame nothing uses. + """ + for joint in joints: + if bands[joint.name] == FULL_RANGE: + continue if not bus.write_angle_limits(joint.id, *FULL_RANGE): raise SystemExit( - f"\nSTOPPED: {name}: goal-range write not verified.\n" + f"\nSTOPPED: {joint.name}: goal-range write not verified.\n" f"Put the arm back with `pixi run arm-limits restore` " f"(from {backup}), then investigate before re-running." ) - print(f"{len(fenced)} fence(s) cleared and confirmed.") -def _phase_centre(bus, joints, recorders, args) -> dict[str, int]: - """Move each joint's zero to the middle of the range just swept. +def _write_fences(bus, joints, calibrated) -> None: + """Fence each joint at its measured stops, in the frame just calibrated. + + Written after the offsets, because the band is compared against the + corrected goal and so only means anything once the frame has stopped moving. + The band is the swept travel, wider than the soft limits by `--margin` at + each end: `arm.yaml` always binds first, so this can never be what stops the + arm in ordinary use, and `arm-limits show` reporting a band narrower than + the configured one therefore means something is wrong rather than meaning + Tuesday. + """ + wrote = [] + for joint in joints: + cal = calibrated.get(joint.name) + if cal is None: + continue + low, high = fence_counts(cal) + if not bus.write_angle_limits(joint.id, low, high): + raise SystemExit( + f"\nSTOPPED: {joint.name}: goal-range write not verified. The " + "zeros and arm.yaml are correct and the joint is unfenced, so " + "the arm is usable -- `pixi run arm-limits show` says which " + "joints have a fence, and `arm-limits restore` puts the " + "as-found ones back." + ) + wrote.append((joint.name, low, high)) + if not wrote: + return + print(f"\n{len(wrote)} joint(s) fenced at their measured stops:") + for name, low, high in wrote: + print(f" {name:<16}{low:>7}{high:>7}") + + +def _report_fences(joints, bands, calibrated) -> None: + """Say which fences now cut the emitted limits, without writing anything. + + `--skip-homing` writes nothing to the servos, and a fence is servo state, so + it stays a report here. A fence narrower than the band being emitted still + has to be said out loud: those limits describe travel the arm will refuse. + """ + cutting = [] + for joint in joints: + cal = calibrated.get(joint.name) + if cal is None: + continue + low, high = bands[joint.name] + want_low, want_high = fence_counts(cal) + if low > want_low or high < want_high: + cutting.append((joint.name, low, high, want_low, want_high)) + if not cutting: + return + print("\nThese servos refuse part of the range just emitted:") + print(f"\n{'joint':<16}{'accepts':>13}{'measured':>13}") + for name, low, high, want_low, want_high in cutting: + print(f"{name:<16}{f'{low}..{high}':>13}{f'{want_low}..{want_high}':>13}") + print( + "\n--skip-homing writes nothing to the servos, so nothing was changed. " + "`pixi run arm-limits clear` hands the range back." + ) + + +def _phase_centre(bus, joints, recorders, calibrated, args) -> dict[str, int]: + """Move each joint's zero to the middle of the range just swept, and refence. The centre comes from the sweep, not from a pose the operator has to hold. Holding all six joints at mid-travel at once is an awkward, unbalanced position, and eyeballing it is less accurate than the measurement already taken — so the arm can be left wherever it ended up. + + The zero and the servo's own goal-range fence are written by one run and + never one without the other. A fence is compared against the corrected goal, + so it outlives the frame it was measured in: moving a zero under an existing + fence leaves it refusing counts that now name different angles, silently. + That is what happened to this arm. """ print("\n=== 2 of 2: centre the zeros ===") - print("Setting each joint's 0 rad to the middle of its swept range. Leave the") - print("arm where it is — this changes what the encoders report, not the arm.") + print("Setting each joint's 0 rad to the middle of its swept range, and") + print("fencing each servo at the stops just measured. Leave the arm where it") + print("is — this changes what the encoders report, not the arm.") existing: dict[str, int] = {} for joint in joints: @@ -248,24 +313,40 @@ def _phase_centre(bus, joints, recorders, args) -> dict[str, int]: f"{existing[joint.name]:>8}{'->':>4}{wanted[joint.name]:>7}" ) + bands = _read_fences(bus, joints) + print("\nThe servos' own goal-range limits fence which goals they accept and") + print("refuse the rest in silence. Each will be rewritten from the travel") + print("just swept, wider than arm.yaml's soft limits by the margin.") + _show_fences(joints, bands, recorders) + stale = {n: v for n, v in wanted.items() if v != existing[n]} - if not stale: - print("\nevery servo is already centred — nothing to write.") + want_fence = {n: fence_counts(c) for n, c in calibrated.items()} + refence = {n: b for n, b in bands.items() if b != want_fence.get(n, b)} + if not stale and not refence: + print("\nevery servo is already centred and fenced — nothing to write.") return wanted - print(f"\nWrites servo EEPROM on {len(stale)} joint(s) — a persistent change.") + print( + f"\nWrites servo EEPROM: {len(stale)} zero(s), {len(refence)} fence(s) " + "— a persistent change." + ) if not _confirm("write? [y/N] ", args.yes): raise SystemExit("aborted; nothing written") # Snapshot before the first write. These values exist nowhere but the # servos, so without this a run that dies partway leaves an arm that cannot # be put back — which is exactly what happened once. - backup = save_offsets_backup( - existing, - {j.name: j.id for j in joints}, - datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%SZ"), - ) + when = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%SZ") + ids = {j.name: j.id for j in joints} + backup = save_offsets_backup(existing, ids, when) print(f"backed up to {backup} (`pixi run arm-offsets restore` undoes this)") + if not limits_backup_path().exists(): + fence_backup = save_limits_backup(bands, ids, when) + print(f"backed up to {fence_backup} (`pixi run arm-limits restore`)") + + # Unfence before the zeros move, so a run that dies in between leaves the + # arm unfenced — recoverable — rather than fenced in a frame nothing uses. + _clear_fences(bus, joints, bands, backup) written: list[str] = [] for joint in joints: @@ -559,10 +640,12 @@ def _run(bus, cfg, selected, args) -> None: if not usable: raise SystemExit("\nno joint produced a usable sweep — nothing to emit") - _clear_fences(bus, usable, recorders, args) - + # The calibration is computed before phase 2 writes anything: it depends only + # on the sweep and the spec, and phase 2 needs to show the fence it is about + # to write alongside the offsets, under one confirmation. offsets: dict[str, int] = {} calibrated: dict = {} + bands: dict = {} if args.skip_homing: print("\n--skip-homing: keeping the zeros already in robot.yaml.") for joint in usable: @@ -573,12 +656,13 @@ def _run(bus, cfg, selected, args) -> None: args.margin, "kept from robot.yaml", ) + bands = _read_fences(bus, usable) else: - offsets = _phase_centre(bus, usable, recorders, args) for joint in usable: calibrated[joint.name] = calibrate_centred( joint, recorders[joint.name].result(), args.margin ) + offsets = _phase_centre(bus, usable, recorders, calibrated, args) recorded = datetime.now(timezone.utc).strftime("measured %Y-%m-%d") @@ -589,6 +673,14 @@ def _run(bus, cfg, selected, args) -> None: print(f" {name:<16} {reason}") _save(cfg, calibrated, offsets, recorded) + # After the save, because a fence write that fails must leave arm.yaml + # already describing the frame the servos are in: an unfenced arm with + # correct limits is usable, a calibrated arm with no record of its zeros is + # not. + if args.skip_homing: + _report_fences(usable, bands, calibrated) + else: + _write_fences(bus, usable, calibrated) _migrate_poses(cfg, calibrated) _next_steps() diff --git a/mote_arm/mote_arm/calibrate.py b/mote_arm/mote_arm/calibrate.py index 26f99b3..7baaef3 100644 --- a/mote_arm/mote_arm/calibrate.py +++ b/mote_arm/mote_arm/calibrate.py @@ -396,6 +396,39 @@ def centred_limits( return (lo, hi) if not invert else (-hi, -lo) +def fence_counts(cal: JointCalibration) -> tuple[int, int]: + """The goal-range fence to write for a calibrated joint: its measured stops. + + Deliberately the swept travel and not the soft limits -- wider by ``margin`` + at each end, so the soft limit in ``arm.yaml`` always binds first and the + servo's own band can never be the thing that stops the arm in ordinary use. + It is the backstop for a soft limit that has gone wrong: a hand-edited + arm.yaml, a URDF that never received one, a servo swapped under a stale + calibration. A fence that binds before the soft limit is the failure this + exists to prevent, not a stricter version of it. + + Computed from the calibration's own zero and band rather than from the raw + sweep, so it lands in whatever frame that calibration describes -- which is + the property the arm lost when a later run moved a zero and left a fence + from an earlier one behind. + """ + sign = -1 if cal.invert else 1 + edges = sorted( + cal.zero_counts + sign * rad / RAD_PER_COUNT + for rad in (cal.min_rad, cal.max_rad) + ) + margin = cal.margin / RAD_PER_COUNT + low = max(0, round(edges[0] - margin)) + high = min(COUNTS_PER_REV - 1, round(edges[1] + margin)) + if low >= high: + raise CalibrationError( + f"{cal.name}: measured travel {low}..{high} counts leaves no goal " + "range to fence.", + reason="measured travel too short to fence", + ) + return low, high + + def _reject_continuous(sweep: Sweep) -> None: if sweep.unwrapped_span >= COUNTS_PER_REV: # No homing offset can rescue this: the joint simply does not fit in a diff --git a/mote_arm/test/test_calibrate_fences.py b/mote_arm/test/test_calibrate_fences.py index 399c171..9d1be4e 100644 --- a/mote_arm/test/test_calibrate_fences.py +++ b/mote_arm/test/test_calibrate_fences.py @@ -1,19 +1,31 @@ -"""`arm-calibrate` clearing the servos' goal-range fence before it moves a zero. - -The fence binds only under torque, so phase 1 sweeps a limp joint straight -through it and measures travel the arm will afterwards refuse to make. Leaving -it in place therefore produces a calibration that describes a range the arm -stops short of, silently, at the same angle every time. The properties held -here: a fenced joint is cleared, an unfenced arm is left alone entirely, the -as-found bands are snapshotted before the first write, and a write that cannot -be verified stops the run rather than continuing into the offsets. +"""`arm-calibrate` writing the servos' goal-range fence with the zeros. + +A fence is compared against the *corrected* goal, so it outlives the frame it +was measured in: move a zero under one and it goes on refusing the same counts, +which now name different angles, in silence. That is how this arm broke — a +LeRobot calibration wrote fence and offset together in May 2026, a later +`arm-calibrate` moved the offsets and left the fence behind, and five of six +joints spent four months stopping short of their own travel at 0% load. + +So the properties held here are: the fence is the *measured stops*, wider than +the soft limits by the margin, so `arm.yaml` always binds first and a fence can +never be what stops the arm in ordinary use; joints are unfenced before their +zeros move, so a run that dies in between leaves a recoverable arm; the +as-found bands are snapshotted before the first write; and `--skip-homing` +reports rather than writes, because it promises to touch no servo. """ import pytest from mote_arm import arm_calibrate -from mote_arm.calibrate import Sweep, load_limits_backup -from mote_arm.config import JointSpec +from mote_arm.calibrate import ( + JointCalibration, + Sweep, + calibrate_centred, + fence_counts, + load_limits_backup, +) +from mote_arm.config import RAD_PER_COUNT, JointSpec FULL = arm_calibrate.FULL_RANGE @@ -35,75 +47,171 @@ def write_angle_limits(self, servo_id, low, high): return True -class FakeRecorder: - def __init__(self, span): - self._span = span +def sweep(low=852, high=3236, name="shoulder_lift"): + return Sweep( + name=name, + samples=1493, + min_counts=low, + max_counts=high, + wraps=0, + unwrapped_min=low, + unwrapped_max=high, + ) - def result(self): - return Sweep( - name="j", - samples=100, - min_counts=852, - max_counts=3236, - wraps=0, - unwrapped_min=852, - unwrapped_max=852 + self._span, - ) +def spec(name="shoulder_lift", servo_id=2, invert=False): + return JointSpec( + name=name, id=servo_id, min_rad=-1.7785, max_rad=1.7785, invert=invert + ) -class Args: - yes = True + +def calibration(**kw): + base = dict( + name="shoulder_lift", + id=2, + invert=False, + zero_counts=2048, + min_rad=-1.0, + max_rad=0.5, + zero_source="the middle of the measured travel", + margin=0.05, + sweep=sweep(), + ) + base.update(kw) + return JointCalibration(**base) + + +# --- fence_counts: the band itself ------------------------------------------ + + +def test_the_fence_is_the_measured_travel_not_the_soft_limits(): + """The whole point: arm.yaml binds first, so a fence never stops the arm.""" + cal = calibrate_centred(spec(), sweep(), margin=0.05) + low, high = fence_counts(cal) + assert high - low == sweep().unwrapped_span + soft_low = cal.zero_counts + round(cal.min_rad / RAD_PER_COUNT) + soft_high = cal.zero_counts + round(cal.max_rad / RAD_PER_COUNT) + assert low < soft_low and high > soft_high + + +def test_the_margin_is_exactly_what_separates_them(): + cal = calibrate_centred(spec(), sweep(), margin=0.05) + low, high = fence_counts(cal) + margin_counts = round(0.05 / RAD_PER_COUNT) + assert low == round(cal.zero_counts + cal.min_rad / RAD_PER_COUNT) - margin_counts + assert high == round(cal.zero_counts + cal.max_rad / RAD_PER_COUNT) + margin_counts + + +def test_an_inverted_joint_fences_the_mirrored_band(): + """counts_to_rad flips the sign, so the low angle is the high count.""" + plain = fence_counts(calibration(invert=False)) + flipped = fence_counts(calibration(invert=True)) + assert plain == (1364, 2407) + assert flipped == (1689, 2732) + + +def test_a_band_running_past_the_encoder_is_clamped_not_wrapped(): + """Wrapping here would fence the far side of the encoder — the whole arm.""" + low, high = fence_counts(calibration(zero_counts=100)) + assert low == 0 + assert high == 100 + round(0.5 / RAD_PER_COUNT) + round(0.05 / RAD_PER_COUNT) + + +# --- the run: unfence, re-frame, re-fence ------------------------------------ def joints(): - return [ - JointSpec(name="shoulder_lift", id=2, min_rad=-1.7785, max_rad=1.7785), - JointSpec(name="wrist_roll", id=5, min_rad=-2.88, max_rad=2.88), - ] + return [spec(), spec(name="wrist_roll", servo_id=5)] -def recorders(): - return {"shoulder_lift": FakeRecorder(2384), "wrist_roll": FakeRecorder(3820)} +def calibrated(): + return { + "shoulder_lift": calibrate_centred(spec(), sweep(), 0.05), + "wrist_roll": calibrate_centred( + spec(name="wrist_roll", servo_id=5), sweep(130, 3950), 0.05 + ), + } -def test_a_fenced_joint_is_cleared(tmp_path, monkeypatch, capsys): - monkeypatch.setenv("MOTE_HOME", str(tmp_path)) +def test_every_joint_is_unfenced_before_its_zero_moves(): bus = FakeBus({2: (1478, 3859), 5: FULL}) - arm_calibrate._clear_fences(bus, joints(), recorders(), Args()) + arm_calibrate._clear_fences( + bus, + joints(), + {"shoulder_lift": (1478, 3859), "wrist_roll": FULL}, + "backup.yaml", + ) + # Only the fenced one is rewritten; the open one is already open. assert bus.written == [(2, 0, 4095)] - assert bus.bands[2] == FULL - # The one joint that already accepted its whole range is not rewritten. - assert "wrist_roll" not in capsys.readouterr().out.split("=== the servos")[1] -def test_the_as_found_bands_are_saved_before_the_first_write(tmp_path, monkeypatch): - monkeypatch.setenv("MOTE_HOME", str(tmp_path)) - bus = FakeBus({2: (1478, 3859), 5: FULL}) - arm_calibrate._clear_fences(bus, joints(), recorders(), Args()) - assert load_limits_backup() == {"shoulder_lift": (1478, 3859), "wrist_roll": FULL} +def test_an_unfenced_arm_is_left_entirely_alone(): + bus = FakeBus({2: FULL, 5: FULL}) + arm_calibrate._clear_fences( + bus, joints(), {"shoulder_lift": FULL, "wrist_roll": FULL}, "backup.yaml" + ) + assert bus.written == [] -def test_an_unfenced_arm_writes_nothing_and_says_nothing(tmp_path, monkeypatch, capsys): - monkeypatch.setenv("MOTE_HOME", str(tmp_path)) +def test_an_unreadable_band_stops_the_run_rather_than_assuming_it_is_open(): + bus = FakeBus({5: FULL}) # servo 2 does not answer + with pytest.raises(SystemExit) as exc: + arm_calibrate._read_fences(bus, joints()) + assert "shoulder_lift" in str(exc.value) + + +def test_the_new_fence_is_written_for_every_calibrated_joint(capsys): bus = FakeBus({2: FULL, 5: FULL}) - arm_calibrate._clear_fences(bus, joints(), recorders(), Args()) - assert bus.written == [] - assert capsys.readouterr().out == "" + cals = calibrated() + arm_calibrate._write_fences(bus, joints(), cals) + assert bus.written == [ + (2, *fence_counts(cals["shoulder_lift"])), + (5, *fence_counts(cals["wrist_roll"])), + ] + assert "fenced at their measured stops" in capsys.readouterr().out -def test_a_write_that_cannot_be_verified_stops_the_run(tmp_path, monkeypatch): - monkeypatch.setenv("MOTE_HOME", str(tmp_path)) - bus = FakeBus({2: (1478, 3859), 5: FULL}, writes_take=False) +def test_a_joint_that_did_not_calibrate_is_left_unfenced(): + """No sweep means no measured stops, and a guessed fence is worse than none.""" + bus = FakeBus({2: FULL, 5: FULL}) + cals = calibrated() + del cals["wrist_roll"] + arm_calibrate._write_fences(bus, joints(), cals) + assert [servo for servo, _, _ in bus.written] == [2] + + +def test_a_fence_write_that_cannot_be_verified_stops_the_run(): + bus = FakeBus({2: FULL, 5: FULL}, writes_take=False) with pytest.raises(SystemExit) as exc: - arm_calibrate._clear_fences(bus, joints(), recorders(), Args()) - assert "arm-limits restore" in str(exc.value) + arm_calibrate._write_fences(bus, joints(), calibrated()) + message = str(exc.value) + assert "arm-limits" in message + # The arm is usable at this point: zeros and arm.yaml are already correct. + assert "usable" in message -def test_an_unreadable_band_stops_the_run_rather_than_assuming_it_is_open( - tmp_path, monkeypatch -): +def test_skip_homing_reports_a_cutting_fence_and_writes_nothing(capsys): + bus = FakeBus({2: (1478, 3859), 5: FULL}) + arm_calibrate._report_fences( + joints(), {"shoulder_lift": (1478, 3859), "wrist_roll": FULL}, calibrated() + ) + assert bus.written == [] + out = capsys.readouterr().out + assert "shoulder_lift" in out and "wrist_roll" not in out + assert "arm-limits clear" in out + + +def test_skip_homing_says_nothing_when_no_fence_cuts(capsys): + arm_calibrate._report_fences( + joints(), {"shoulder_lift": FULL, "wrist_roll": FULL}, calibrated() + ) + assert capsys.readouterr().out == "" + + +def test_the_as_found_bands_round_trip_through_the_backup(tmp_path, monkeypatch): + from mote_arm.calibrate import save_limits_backup + monkeypatch.setenv("MOTE_HOME", str(tmp_path)) - bus = FakeBus({5: FULL}) # servo 2 does not answer - with pytest.raises(SystemExit) as exc: - arm_calibrate._clear_fences(bus, joints(), recorders(), Args()) - assert "shoulder_lift" in str(exc.value) + found = {"shoulder_lift": (1478, 3859), "wrist_roll": FULL} + save_limits_backup(found, {"shoulder_lift": 2, "wrist_roll": 5}, "now") + assert load_limits_backup() == found diff --git a/mote_arm/test/test_calibrate_phase2.py b/mote_arm/test/test_calibrate_phase2.py new file mode 100644 index 0000000..6c085fb --- /dev/null +++ b/mote_arm/test/test_calibrate_phase2.py @@ -0,0 +1,185 @@ +"""Phase 2 end to end against a fake bus: one confirmation, both EEPROM writes. + +The zero and the servo's own goal-range fence are written by one run and never +one without the other, because a fence is compared against the corrected goal +and so outlives the frame it was measured in. Splitting them is what left this +arm capped for four months. This drives the whole phase rather than its parts, +so an ordering mistake — fencing before the offset moves, backing up after the +first write — fails here rather than on hardware. +""" + +from mote_arm import arm_calibrate +from mote_arm.calibrate import ( + Sweep, + calibrate_centred, + load_limits_backup, + load_offsets_backup, +) +from mote_arm.config import JointSpec + +FULL = arm_calibrate.FULL_RANGE + + +class FakeBus: + """A servo pair whose position reading follows its offset register.""" + + def __init__(self, offsets, bands, positions): + self.offsets = dict(offsets) + self.bands = dict(bands) + self.positions = dict(positions) + self.log = [] + + def read_homing_offset(self, servo_id): + return self.offsets[servo_id] + + def write_homing_offset(self, servo_id, value): + # present = actual - offset, so the reading moves by the delta. + self.positions[servo_id] = ( + self.positions[servo_id] - (value - self.offsets[servo_id]) + ) % 4096 + self.offsets[servo_id] = value + self.log.append(("offset", servo_id, value)) + return True + + def read_angle_limits(self, servo_id): + return self.bands[servo_id] + + def write_angle_limits(self, servo_id, low, high): + self.bands[servo_id] = (low, high) + self.log.append(("fence", servo_id, low, high)) + return True + + def read_position(self, servo_id): + return self.positions[servo_id] + + def read_position_settled(self, servo_id, **_kw): + return self.positions[servo_id] + + +class Recorder: + def __init__(self, low, high, name): + self._sweep = Sweep( + name=name, + samples=1493, + min_counts=low, + max_counts=high, + wraps=0, + unwrapped_min=low, + unwrapped_max=high, + ) + + def result(self): + return self._sweep + + +class Args: + yes = True + + +JOINTS = [ + JointSpec(name="shoulder_lift", id=2, min_rad=-1.7785, max_rad=1.7785), + JointSpec(name="elbow_flex", id=3, min_rad=-1.6458, max_rad=1.6458), +] +RECORDERS = { + "shoulder_lift": Recorder(852, 3236, "shoulder_lift"), + "elbow_flex": Recorder(949, 3160, "elbow_flex"), +} + + +def calibrated(): + return { + j.name: calibrate_centred(j, RECORDERS[j.name].result(), 0.05) for j in JOINTS + } + + +def fresh_bus(): + # The state this arm was actually in: fenced by LeRobot in May, offsets + # moved by a later calibration, fence left behind. + return FakeBus( + offsets={2: -1103, 3: 1551}, + bands={2: (1478, 3859), 3: (716, 2941)}, + positions={2: 861, 3: 3146}, + ) + + +def run(bus, tmp_path, monkeypatch): + monkeypatch.setenv("MOTE_HOME", str(tmp_path)) + return arm_calibrate._phase_centre(bus, JOINTS, RECORDERS, calibrated(), Args()) + + +def test_the_fence_comes_off_before_any_zero_moves(tmp_path, monkeypatch): + bus = fresh_bus() + run(bus, tmp_path, monkeypatch) + kinds = [entry[0] for entry in bus.log] + assert kinds.index("fence") < kinds.index("offset") + assert [e for e in bus.log if e[0] == "fence"] == [ + ("fence", 2, 0, 4095), + ("fence", 3, 0, 4095), + ] + + +def test_both_backups_are_written_before_the_first_write(tmp_path, monkeypatch): + bus = fresh_bus() + run(bus, tmp_path, monkeypatch) + assert load_offsets_backup() == {"shoulder_lift": -1103, "elbow_flex": 1551} + assert load_limits_backup() == { + "shoulder_lift": (1478, 3859), + "elbow_flex": (716, 2941), + } + + +def test_phase_two_leaves_the_arm_unfenced_for_write_fences_to_finish( + tmp_path, monkeypatch +): + """The intermediate state is deliberate: a crash here is recoverable.""" + bus = fresh_bus() + run(bus, tmp_path, monkeypatch) + assert bus.bands == {2: FULL, 3: FULL} + + +# A second sweep, taken after a calibration: its centre is already 2048, so the +# offsets it asks for are the ones the servos hold. That is the settled arm. +SETTLED = { + "shoulder_lift": Recorder(856, 3240, "shoulder_lift"), + "elbow_flex": Recorder(943, 3153, "elbow_flex"), +} + + +def settled_calibration(): + return { + j.name: calibrate_centred(j, SETTLED[j.name].result(), 0.05) for j in JOINTS + } + + +def test_a_centred_and_correctly_fenced_arm_writes_nothing( + tmp_path, monkeypatch, capsys +): + cals = settled_calibration() + bus = FakeBus( + offsets={2: -1107, 3: 1557}, + bands={j.id: arm_calibrate.fence_counts(cals[j.name]) for j in JOINTS}, + positions={2: 2048, 3: 2048}, + ) + monkeypatch.setenv("MOTE_HOME", str(tmp_path)) + arm_calibrate._phase_centre(bus, JOINTS, SETTLED, cals, Args()) + assert bus.log == [] + assert "already centred and fenced" in capsys.readouterr().out + + +def test_a_stale_fence_is_rewritten_even_when_the_zeros_are_already_right( + tmp_path, monkeypatch +): + """The case that hid for four months: right zeros, fence from an old frame.""" + cals = settled_calibration() + bus = FakeBus( + offsets={2: -1107, 3: 1557}, + bands={2: (1478, 3859), 3: (716, 2941)}, + positions={2: 2048, 3: 2048}, + ) + monkeypatch.setenv("MOTE_HOME", str(tmp_path)) + arm_calibrate._phase_centre(bus, JOINTS, SETTLED, cals, Args()) + assert bus.bands == {2: FULL, 3: FULL} # unfenced in between + arm_calibrate._write_fences(bus, JOINTS, cals) + assert bus.bands == {j.id: arm_calibrate.fence_counts(cals[j.name]) for j in JOINTS} + # No zero moved: only the fence was wrong. + assert [e for e in bus.log if e[0] == "offset"] == [] From b68fa3e5a9a2176a5263adf22338b806a94c40f7 Mon Sep 17 00:00:00 2001 From: Michael Johnson Date: Thu, 3 Sep 2026 10:29:59 +0100 Subject: [PATCH 12/22] Fence each joint straight after its own zero, never unfence first Clearing the fence before moving the zeros cost an EEPROM write per joint and manufactured the very gap it was meant to avoid: every run passed through a state with no fence at all, and a run that died there left one. Nothing commands the arm during calibration, so a stale fence obstructs nothing while the offsets are written -- there was never anything for the clear to protect. Each joint's fence now goes on immediately after its own offset. After, because the band is compared against the corrected goal and means the wrong angles until the frame has moved. Immediately, because the pair is what has to agree: a joint holds either its old band or its new one, and the window where the two disagree is one bus transaction wide. `_clear_fences` and `_write_fences` are gone with it, and so is arm_calibrate's FULL_RANGE -- clearing is `arm-limits clear`'s job, for diagnosis. The fence table now shows what each servo accepts against what it will be fenced at, rather than against the whole register, which is the comparison that matters once a fence is expected rather than exceptional. _abort_partial names `arm-limits restore` beside `arm-offsets restore`. 247 tests pass. test_calibrate_phase2.py pins the log as offset/fence/offset/fence and asserts 0..4095 is never written. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_017hfWyNtkVh16hyvCK8K9cd --- CLAUDE.md | 11 +- mote_arm/BENCH.md | 10 +- mote_arm/README.md | 16 +-- mote_arm/mote_arm/arm_calibrate.py | 140 ++++++++----------------- mote_arm/test/test_calibrate_fences.py | 64 ++--------- mote_arm/test/test_calibrate_phase2.py | 48 +++++---- 6 files changed, 97 insertions(+), 192 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 84c7d1f..0430c24 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -846,10 +846,13 @@ section. Contains: full-turn and skips it, and `shoulder_pan` 760 counts short because its unwrapped min/max `record_ranges_of_motion` mis-records a wrap-crossing joint, which shoulder_pan is. So **`arm-calibrate` now writes the fence and the zero - in one run and never one without the other**: unfence, move the zeros, fence - again at the stops just measured, both as-found sets snapshotted first - (`arm_offsets_backup.yaml`, `arm_limits_backup.yaml`), the intermediate state - deliberately *unfenced* so a run that dies between them is recoverable. + in one run and never one without the other**: each joint's fence goes on + immediately after its own offset — *after*, because the band is compared + against the corrected goal and means the wrong angles until the frame has + moved; *immediately*, because the pair is what has to agree. No joint is ever + left unfenced (clearing first would cost a write per joint and manufacture the + gap), and both as-found sets are snapshotted first (`arm_offsets_backup.yaml`, + `arm_limits_backup.yaml`). `--skip-homing` promises to touch no servo, so it reports a cutting fence rather than correcting it. **The band written is the measured travel, not the soft limits** (`calibrate.fence_counts`) — wider by `--margin` at each end, so diff --git a/mote_arm/BENCH.md b/mote_arm/BENCH.md index 15c158f..188ef42 100644 --- a/mote_arm/BENCH.md +++ b/mote_arm/BENCH.md @@ -191,11 +191,11 @@ straight through it: a fence left behind describes travel the arm will refuse to make, silently, stopping at the same angle every time as if it had run out of torque. This arm spent four months in exactly that state. -The order is unfence, move the zeros, fence again at the stops just measured. -The new band is wider than the soft limits in `arm.yaml` by `--margin` at each -end, so the soft limits always stop the arm first and the fence only acts if -they have gone wrong. `--skip-homing` writes nothing to the servos, so it -reports a cutting fence rather than correcting it. +Each joint's fence is written immediately after its own zero, so no joint is +ever left without one. The new band is wider than the soft limits in `arm.yaml` +by `--margin` at each end, so the soft limits always stop the arm first and the +fence only acts if they have gone wrong. `--skip-homing` writes nothing to the +servos, so it reports a cutting fence rather than correcting it. The as-found bands are snapshotted to `~/.mote/arm_limits_backup.yaml` before the first write, so: diff --git a/mote_arm/README.md b/mote_arm/README.md index 5465f10..47bd5a0 100644 --- a/mote_arm/README.md +++ b/mote_arm/README.md @@ -418,13 +418,15 @@ pixi run arm-limits restore # write the as-found bands back ``` **The fence and the zero are now written by one run, and never one without the -other.** Phase 2 unfences every joint, moves the zeros, and then fences each -joint at the stops it just measured — in that order, so a run that dies in -between leaves an unfenced arm, which is recoverable, rather than one fenced in -a frame nothing uses. Both as-found sets are snapshotted first -(`arm_offsets_backup.yaml`, `arm_limits_backup.yaml`); neither exists anywhere -else. `--skip-homing` promises to write nothing to the servos, so it reports a -cutting fence instead of correcting it. +other.** Phase 2 writes each joint's fence immediately after its own offset — +after, because the band is compared against the corrected goal and so means the +wrong angles until the frame has moved; immediately, because the pair is what +has to agree. Nothing is unfenced in between: a joint holds either its old band +or its new one, and the window where the two disagree is one bus transaction +wide. Both as-found sets are snapshotted first (`arm_offsets_backup.yaml`, +`arm_limits_backup.yaml`); neither exists anywhere else. `--skip-homing` +promises to write nothing to the servos, so it reports a cutting fence instead +of correcting it. **The band written is the measured travel, not the soft limits** — wider by `--margin` (0.05 rad) at each end. That is what makes it a backstop rather than diff --git a/mote_arm/mote_arm/arm_calibrate.py b/mote_arm/mote_arm/arm_calibrate.py index b26916e..71c2c2f 100644 --- a/mote_arm/mote_arm/arm_calibrate.py +++ b/mote_arm/mote_arm/arm_calibrate.py @@ -55,7 +55,7 @@ from mote_arm import config, poses -from mote_arm.bus import COUNTS_PER_TURN, BusError, FeetechBus, port_holders +from mote_arm.bus import BusError, FeetechBus, port_holders from mote_arm.calibrate import ( DEFAULT_MARGIN, CalibrationError, @@ -76,9 +76,6 @@ ) from mote_arm.config import RAD_PER_COUNT -# The whole single-turn goal range, which a centred zero assumes it has. -FULL_RANGE = (0, COUNTS_PER_TURN - 1) - def _open_bus(cfg) -> FeetechBus: holders = port_holders(cfg.port) @@ -172,77 +169,22 @@ def _read_fences(bus, joints) -> dict[str, tuple[int, int]]: return bands -def _show_fences(joints, bands, recorders) -> None: - fenced = {n: b for n, b in bands.items() if b != FULL_RANGE} - if not fenced: - print("\nNo joint is fenced today; one will be written from the sweep.") +def _show_fences(joints, bands, want_fence) -> None: + """What each servo accepts now against what this run will fence it at.""" + changing = [j for j in joints if bands[j.name] != want_fence.get(j.name)] + if not changing: + print("\nEvery servo already accepts exactly its measured travel.") return - print(f"\n{'joint':<16}{'accepts now':>13}{'counts swept':>14}{'refused':>9}") - for joint in joints: - if joint.name not in fenced: + print(f"\n{'joint':<16}{'accepts now':>13}{'->':>4}{'measured stops':>16}") + for joint in changing: + low, high = bands[joint.name] + want = want_fence.get(joint.name) + if want is None: continue - low, high = fenced[joint.name] - swept = recorders[joint.name].result().unwrapped_span print( - f"{joint.name:<16}{f'{low}..{high}':>13}{swept:>14}" - f"{max(0, swept - (high - low)):>9}" + f"{joint.name:<16}{f'{low}..{high}':>13}{'->':>4}" + f"{f'{want[0]}..{want[1]}':>16}" ) - print("'refused' is how many counts of measured travel the servo will not go to.") - - -def _clear_fences(bus, joints, bands, backup) -> None: - """Unfence every joint before its zero moves. - - The band is compared against the *corrected* goal, so a fence outlives the - frame it was measured in: move the zero under one and it goes on refusing - the same counts, which are now different physical angles. That is exactly - how this arm broke -- LeRobot wrote fence and offset together in May, and a - later `arm-calibrate` moved the offsets and left the fence behind. Clearing - first means a run that dies between here and `_write_fences` leaves the arm - unfenced, which is recoverable, rather than fenced in a frame nothing uses. - """ - for joint in joints: - if bands[joint.name] == FULL_RANGE: - continue - if not bus.write_angle_limits(joint.id, *FULL_RANGE): - raise SystemExit( - f"\nSTOPPED: {joint.name}: goal-range write not verified.\n" - f"Put the arm back with `pixi run arm-limits restore` " - f"(from {backup}), then investigate before re-running." - ) - - -def _write_fences(bus, joints, calibrated) -> None: - """Fence each joint at its measured stops, in the frame just calibrated. - - Written after the offsets, because the band is compared against the - corrected goal and so only means anything once the frame has stopped moving. - The band is the swept travel, wider than the soft limits by `--margin` at - each end: `arm.yaml` always binds first, so this can never be what stops the - arm in ordinary use, and `arm-limits show` reporting a band narrower than - the configured one therefore means something is wrong rather than meaning - Tuesday. - """ - wrote = [] - for joint in joints: - cal = calibrated.get(joint.name) - if cal is None: - continue - low, high = fence_counts(cal) - if not bus.write_angle_limits(joint.id, low, high): - raise SystemExit( - f"\nSTOPPED: {joint.name}: goal-range write not verified. The " - "zeros and arm.yaml are correct and the joint is unfenced, so " - "the arm is usable -- `pixi run arm-limits show` says which " - "joints have a fence, and `arm-limits restore` puts the " - "as-found ones back." - ) - wrote.append((joint.name, low, high)) - if not wrote: - return - print(f"\n{len(wrote)} joint(s) fenced at their measured stops:") - for name, low, high in wrote: - print(f" {name:<16}{low:>7}{high:>7}") def _report_fences(joints, bands, calibrated) -> None: @@ -314,13 +256,13 @@ def _phase_centre(bus, joints, recorders, calibrated, args) -> dict[str, int]: ) bands = _read_fences(bus, joints) + want_fence = {n: fence_counts(c) for n, c in calibrated.items()} print("\nThe servos' own goal-range limits fence which goals they accept and") - print("refuse the rest in silence. Each will be rewritten from the travel") - print("just swept, wider than arm.yaml's soft limits by the margin.") - _show_fences(joints, bands, recorders) + print("refuse the rest in silence. Each is rewritten to the travel just") + print("swept, wider than arm.yaml's soft limits by the margin.") + _show_fences(joints, bands, want_fence) stale = {n: v for n, v in wanted.items() if v != existing[n]} - want_fence = {n: fence_counts(c) for n, c in calibrated.items()} refence = {n: b for n, b in bands.items() if b != want_fence.get(n, b)} if not stale and not refence: print("\nevery servo is already centred and fenced — nothing to write.") @@ -344,30 +286,35 @@ def _phase_centre(bus, joints, recorders, calibrated, args) -> dict[str, int]: fence_backup = save_limits_backup(bands, ids, when) print(f"backed up to {fence_backup} (`pixi run arm-limits restore`)") - # Unfence before the zeros move, so a run that dies in between leaves the - # arm unfenced — recoverable — rather than fenced in a frame nothing uses. - _clear_fences(bus, joints, bands, backup) - written: list[str] = [] for joint in joints: - if joint.name not in stale: - continue - # Read again here rather than reusing the pre-prompt reading: the arm is - # limp and may have sagged while the operator answered. - was = bus.read_position(joint.id) - ok = bus.write_homing_offset(joint.id, wanted[joint.name]) - if not ok: - _abort_partial(written, backup, f"{joint.name}: write not verified") - moved = _reading_moved_as_expected( - bus, joint, was, existing[joint.name], wanted[joint.name] - ) - if moved is not None: - _abort_partial(written, backup, moved) + if joint.name in stale: + # Read again here rather than reusing the pre-prompt reading: the + # arm is limp and may have sagged while the operator answered. + was = bus.read_position(joint.id) + ok = bus.write_homing_offset(joint.id, wanted[joint.name]) + if not ok: + _abort_partial(written, backup, f"{joint.name}: write not verified") + moved = _reading_moved_as_expected( + bus, joint, was, existing[joint.name], wanted[joint.name] + ) + if moved is not None: + _abort_partial(written, backup, moved) + if joint.name in refence: + # Immediately after the zero it belongs to, and never before: the + # band is compared against the corrected goal, so it means the wrong + # angles until the frame has moved. Nothing is unfenced in between — + # a joint holds either its old band or its new one, and the window + # where those disagree is one bus transaction wide. + if not bus.write_angle_limits(joint.id, *want_fence[joint.name]): + _abort_partial( + written, backup, f"{joint.name}: fence write not verified" + ) written.append(joint.name) # One line, not one per joint: every name here succeeded, and a joint that # did not has already stopped the run by name with the detail that matters. - print(f"{len(written)} joint(s) centred and confirmed.") + print(f"{len(written)} joint(s) centred and fenced, confirmed.") return wanted @@ -378,6 +325,7 @@ def _abort_partial(written: list[str], backup, why: str) -> None: f"{len(written)} servo(s) were changed before this: {written or 'none'}.\n" f"The arm is part-way through a calibration. Put it back with:\n" f" pixi run arm-offsets restore # from {backup}\n" + " pixi run arm-limits restore # the goal-range bands\n" "then investigate before re-running." ) @@ -673,14 +621,8 @@ def _run(bus, cfg, selected, args) -> None: print(f" {name:<16} {reason}") _save(cfg, calibrated, offsets, recorded) - # After the save, because a fence write that fails must leave arm.yaml - # already describing the frame the servos are in: an unfenced arm with - # correct limits is usable, a calibrated arm with no record of its zeros is - # not. if args.skip_homing: _report_fences(usable, bands, calibrated) - else: - _write_fences(bus, usable, calibrated) _migrate_poses(cfg, calibrated) _next_steps() diff --git a/mote_arm/test/test_calibrate_fences.py b/mote_arm/test/test_calibrate_fences.py index 9d1be4e..c131de8 100644 --- a/mote_arm/test/test_calibrate_fences.py +++ b/mote_arm/test/test_calibrate_fences.py @@ -9,15 +9,15 @@ So the properties held here are: the fence is the *measured stops*, wider than the soft limits by the margin, so `arm.yaml` always binds first and a fence can -never be what stops the arm in ordinary use; joints are unfenced before their -zeros move, so a run that dies in between leaves a recoverable arm; the -as-found bands are snapshotted before the first write; and `--skip-homing` -reports rather than writes, because it promises to touch no servo. +never be what stops the arm in ordinary use; and `--skip-homing` reports rather +than writes, because it promises to touch no servo. The ordering property — a +joint's fence written straight after its own zero — is in +`test_calibrate_phase2.py`, which drives the phase that does both. """ import pytest -from mote_arm import arm_calibrate +from mote_arm import arm_calibrate, arm_limits from mote_arm.calibrate import ( JointCalibration, Sweep, @@ -27,7 +27,7 @@ ) from mote_arm.config import RAD_PER_COUNT, JointSpec -FULL = arm_calibrate.FULL_RANGE +FULL = arm_limits.FULL_RANGE class FakeBus: @@ -117,7 +117,7 @@ def test_a_band_running_past_the_encoder_is_clamped_not_wrapped(): assert high == 100 + round(0.5 / RAD_PER_COUNT) + round(0.05 / RAD_PER_COUNT) -# --- the run: unfence, re-frame, re-fence ------------------------------------ +# --- what --skip-homing reports instead of writing --------------------------- def joints(): @@ -133,26 +133,6 @@ def calibrated(): } -def test_every_joint_is_unfenced_before_its_zero_moves(): - bus = FakeBus({2: (1478, 3859), 5: FULL}) - arm_calibrate._clear_fences( - bus, - joints(), - {"shoulder_lift": (1478, 3859), "wrist_roll": FULL}, - "backup.yaml", - ) - # Only the fenced one is rewritten; the open one is already open. - assert bus.written == [(2, 0, 4095)] - - -def test_an_unfenced_arm_is_left_entirely_alone(): - bus = FakeBus({2: FULL, 5: FULL}) - arm_calibrate._clear_fences( - bus, joints(), {"shoulder_lift": FULL, "wrist_roll": FULL}, "backup.yaml" - ) - assert bus.written == [] - - def test_an_unreadable_band_stops_the_run_rather_than_assuming_it_is_open(): bus = FakeBus({5: FULL}) # servo 2 does not answer with pytest.raises(SystemExit) as exc: @@ -160,36 +140,6 @@ def test_an_unreadable_band_stops_the_run_rather_than_assuming_it_is_open(): assert "shoulder_lift" in str(exc.value) -def test_the_new_fence_is_written_for_every_calibrated_joint(capsys): - bus = FakeBus({2: FULL, 5: FULL}) - cals = calibrated() - arm_calibrate._write_fences(bus, joints(), cals) - assert bus.written == [ - (2, *fence_counts(cals["shoulder_lift"])), - (5, *fence_counts(cals["wrist_roll"])), - ] - assert "fenced at their measured stops" in capsys.readouterr().out - - -def test_a_joint_that_did_not_calibrate_is_left_unfenced(): - """No sweep means no measured stops, and a guessed fence is worse than none.""" - bus = FakeBus({2: FULL, 5: FULL}) - cals = calibrated() - del cals["wrist_roll"] - arm_calibrate._write_fences(bus, joints(), cals) - assert [servo for servo, _, _ in bus.written] == [2] - - -def test_a_fence_write_that_cannot_be_verified_stops_the_run(): - bus = FakeBus({2: FULL, 5: FULL}, writes_take=False) - with pytest.raises(SystemExit) as exc: - arm_calibrate._write_fences(bus, joints(), calibrated()) - message = str(exc.value) - assert "arm-limits" in message - # The arm is usable at this point: zeros and arm.yaml are already correct. - assert "usable" in message - - def test_skip_homing_reports_a_cutting_fence_and_writes_nothing(capsys): bus = FakeBus({2: (1478, 3859), 5: FULL}) arm_calibrate._report_fences( diff --git a/mote_arm/test/test_calibrate_phase2.py b/mote_arm/test/test_calibrate_phase2.py index 6c085fb..25ff329 100644 --- a/mote_arm/test/test_calibrate_phase2.py +++ b/mote_arm/test/test_calibrate_phase2.py @@ -1,14 +1,16 @@ """Phase 2 end to end against a fake bus: one confirmation, both EEPROM writes. -The zero and the servo's own goal-range fence are written by one run and never -one without the other, because a fence is compared against the corrected goal -and so outlives the frame it was measured in. Splitting them is what left this -arm capped for four months. This drives the whole phase rather than its parts, +Each joint's fence is written straight after its own zero, because a fence is +compared against the corrected goal and so outlives the frame it was measured +in: the pair is what has to agree, and splitting them is what left this arm +capped for four months. Nothing is unfenced in between — a joint holds either +its old band or its new one. This drives the whole phase rather than its parts, so an ordering mistake — fencing before the offset moves, backing up after the -first write — fails here rather than on hardware. +first write, a gap where a joint has no fence — fails here rather than on +hardware. """ -from mote_arm import arm_calibrate +from mote_arm import arm_calibrate, arm_limits from mote_arm.calibrate import ( Sweep, calibrate_centred, @@ -17,7 +19,7 @@ ) from mote_arm.config import JointSpec -FULL = arm_calibrate.FULL_RANGE +FULL = arm_limits.FULL_RANGE class FakeBus: @@ -107,17 +109,27 @@ def run(bus, tmp_path, monkeypatch): return arm_calibrate._phase_centre(bus, JOINTS, RECORDERS, calibrated(), Args()) -def test_the_fence_comes_off_before_any_zero_moves(tmp_path, monkeypatch): +def test_each_joint_is_fenced_straight_after_its_own_zero(tmp_path, monkeypatch): + """The pair is what must agree, so nothing comes between them.""" bus = fresh_bus() run(bus, tmp_path, monkeypatch) - kinds = [entry[0] for entry in bus.log] - assert kinds.index("fence") < kinds.index("offset") - assert [e for e in bus.log if e[0] == "fence"] == [ - ("fence", 2, 0, 4095), - ("fence", 3, 0, 4095), + cals = calibrated() + assert bus.log == [ + ("offset", 2, -1107), + ("fence", 2, *arm_calibrate.fence_counts(cals["shoulder_lift"])), + ("offset", 3, 1557), + ("fence", 3, *arm_calibrate.fence_counts(cals["elbow_flex"])), ] +def test_no_joint_is_ever_left_without_a_fence(tmp_path, monkeypatch): + """Unfencing first would cost a write per joint and manufacture the gap.""" + bus = fresh_bus() + run(bus, tmp_path, monkeypatch) + fences = [(entry[2], entry[3]) for entry in bus.log if entry[0] == "fence"] + assert FULL not in fences + + def test_both_backups_are_written_before_the_first_write(tmp_path, monkeypatch): bus = fresh_bus() run(bus, tmp_path, monkeypatch) @@ -128,13 +140,11 @@ def test_both_backups_are_written_before_the_first_write(tmp_path, monkeypatch): } -def test_phase_two_leaves_the_arm_unfenced_for_write_fences_to_finish( - tmp_path, monkeypatch -): - """The intermediate state is deliberate: a crash here is recoverable.""" +def test_the_arm_ends_fenced_at_its_measured_stops(tmp_path, monkeypatch): bus = fresh_bus() run(bus, tmp_path, monkeypatch) - assert bus.bands == {2: FULL, 3: FULL} + cals = calibrated() + assert bus.bands == {j.id: arm_calibrate.fence_counts(cals[j.name]) for j in JOINTS} # A second sweep, taken after a calibration: its centre is already 2048, so the @@ -178,8 +188,6 @@ def test_a_stale_fence_is_rewritten_even_when_the_zeros_are_already_right( ) monkeypatch.setenv("MOTE_HOME", str(tmp_path)) arm_calibrate._phase_centre(bus, JOINTS, SETTLED, cals, Args()) - assert bus.bands == {2: FULL, 3: FULL} # unfenced in between - arm_calibrate._write_fences(bus, JOINTS, cals) assert bus.bands == {j.id: arm_calibrate.fence_counts(cals[j.name]) for j in JOINTS} # No zero moved: only the fence was wrong. assert [e for e in bus.log if e[0] == "offset"] == [] From 8201d09994336db022ce6bde3a096e626f44f011 Mon Sep 17 00:00:00 2001 From: Michael Johnson Date: Thu, 3 Sep 2026 11:11:30 +0100 Subject: [PATCH 13/22] arm-pose: store poses that can be reached, and size the travel guard off the arm Two defects found in one bench sitting, both from numbers that predate the arm being calibrated. `save` stored what /joint_states reported, raw. Posing by hand is posing a limp arm against its mechanical stops, and the soft limits sit --margin (0.05 rad) inside those, so a captured position is routinely a fraction outside the band: measured at the bench, elbow_flex 0.012 rad past and gripper 0.042 rad past. Such a pose can never be replayed -- every `go` clamps it and says CLAMPED, which is a warning the operator gets minutes later and cannot act on. `save` now clamps into the band and names the joints it held there, so a stored pose is reachable by construction and differs from what was posed by at most the margin. A joint further out than the margin is reported separately: that means the arm and arm.yaml disagree about its limits, not that someone leaned on a stop. `--max-travel` defaulted to 0.35 rad, chosen when the packaged limits were the old `arm-pose limits` envelope whose bands were ~0.2 rad -- wider than a whole joint's configured range, so it fired on nothing. Calibration gave the joints their real ~3.5 rad bands and left the guard refusing the ordinary case: teach a pose, let go, watch the limp arm fall to rest, replay. It now defaults to the widest travel any joint on this arm has, so it refuses an impossible move rather than a merely large one, and the flag stays for a deliberately tighter bench run. What keeps a `go` safe was never that number: setpoints are streamed at --speed so the arm moves continuously rather than lurching, --max-lag stops it if the arm falls behind, and every move is confirmed unless --yes. Also documented, because it is what made the guard fire and reads as a fault: the arm is limp whenever no controller holds it, so it falls to rest the moment you let go. A `go` straight after a `save` starts from the rest position, not from the pose just taught. Observed as elbow_flex reading -1.598 at save and +1.684 at go, with pan, wrist_roll and gripper unchanged -- the three joints that moved are exactly the pitch joints, and they landed on counts 862 and 3146, which is where every prior read of this arm at rest has found them. 254 tests pass. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_017hfWyNtkVh16hyvCK8K9cd --- mote_arm/README.md | 21 +++++ mote_arm/mote_arm/arm_pose.py | 118 +++++++++++++++++++++++---- mote_arm/test/test_arm_pose_reach.py | 77 +++++++++++++++++ 3 files changed, 201 insertions(+), 15 deletions(-) create mode 100644 mote_arm/test/test_arm_pose_reach.py diff --git a/mote_arm/README.md b/mote_arm/README.md index 47bd5a0..c169b1e 100644 --- a/mote_arm/README.md +++ b/mote_arm/README.md @@ -496,6 +496,27 @@ Lag is measured against `/joint_states`, which the hardware refreshes one arm joint per control cycle to stay inside the bus budget it shares with the wheels, so a stall is caught within a few setpoints rather than instantly. +**A taught pose is stored reachable.** `save` clamps each joint into its soft +band and names the ones it held there. Posing by hand is posing a limp arm +against its stops, and the soft limits sit `--margin` (0.05 rad) inside those, +so a raw capture is routinely a fraction outside the band — measured at the +bench, `elbow_flex` 0.012 rad past and `gripper` 0.042 rad past. Stored raw, +such a pose can never be replayed: every `go` clamps it and says so, which is a +warning that arrives minutes late. A joint further out than the margin is +reported separately, because that means the arm and `arm.yaml` disagree about +where the limits are rather than that the operator leaned on a stop. + +**`go`'s travel ceiling is sized off the arm.** `--max-travel` defaults to the +widest travel any joint has, so it refuses an impossible move rather than a +merely large one. It was 0.35 rad, chosen when the packaged limits were the old +`arm-pose limits` envelope whose bands were ~0.2 rad — wider than a whole +joint's range, so it fired on nothing. Calibration gave the joints their real +~3.5 rad bands and left the guard refusing the ordinary case: teach a pose, let +go, watch the limp arm fall to rest, replay. What keeps a `go` safe is not that +number — setpoints are streamed at `--speed` so the arm moves continuously +rather than lurching, `--max-lag` stops it if the arm falls behind, and every +move is confirmed unless `--yes`. + ## Torque policy Nothing moves without an explicit command. The policy did not change when the diff --git a/mote_arm/mote_arm/arm_pose.py b/mote_arm/mote_arm/arm_pose.py index 7ea314d..d4713bf 100644 --- a/mote_arm/mote_arm/arm_pose.py +++ b/mote_arm/mote_arm/arm_pose.py @@ -9,13 +9,22 @@ pixi run arm-pose go # move to a taught pose pixi run arm-pose delete -``save`` is read-only — pose the limp arm by hand, then capture it. ``go`` is -the only command that moves the arm, and it leaves the arm *holding* the pose it -reached (deactivate ``arm_controller``, or run ``arm-jog`` and ``torque off``, to -make it limp again): it reports the distance each joint will -travel, requires confirmation unless ``--yes`` is given, and refuses moves whose -largest single-joint travel exceeds ``--max-travel``. Goals are clamped to the -robot.yaml soft limits here *and* in the driver. +``save`` moves nothing — pose the limp arm by hand, then capture it. It stores +each joint clamped into its soft band and says which ones it held there: posing +by hand means posing against the mechanical stops, and the soft limits sit a +margin inside those, so a raw capture is routinely a fraction outside the band +and could never be replayed. ``go`` is the only command that moves the arm, and +it leaves the arm *holding* the pose it reached (deactivate ``arm_controller``, +or run ``arm-jog`` and ``torque off``, to make it limp again): it reports the +distance each joint will travel, requires confirmation unless ``--yes`` is +given, and refuses moves whose largest single-joint travel exceeds +``--max-travel`` — by default the widest travel any joint on this arm has, so it +fires on an impossible move rather than a merely large one. Goals are clamped to +the soft limits here *and* in the driver. + +The arm is limp whenever no controller holds it, so it falls to rest the moment +you let go of it. A `go` straight after a `save` therefore starts from the rest +position and not from the pose just taught, and the travel it reports is real. ``go`` streams setpoints at a fixed rate rather than commanding the destination in one jump, so the arm moves continuously at ``--speed`` instead of lurching, @@ -38,6 +47,7 @@ from sensor_msgs.msg import JointState from mote_arm import cli, config, poses +from mote_arm.calibrate import DEFAULT_MARGIN from mote_arm.control import ArmControl from mote_arm.motion import LagSupervisor, lag_of @@ -84,12 +94,80 @@ def _require_states(node: PoseClient) -> dict[str, float]: return node.current() +def widest_travel(cfg) -> float: + """The largest distance any one joint on this arm can legally be sent. + + The default ceiling for `go`. The 0.35 rad it replaces was chosen when the + packaged limits were the old `arm-pose limits` envelope, whose bands were + ~0.2 rad -- so 0.35 was wider than a whole joint's configured range and + fired on nothing. Calibration then gave the joints their real ~3.5 rad + bands and left the guard firing on almost every real move, including the + ordinary one: teach a pose, let go of a limp arm, watch it fall to rest, + replay. Sized off the arm, it fires only on a move that is impossible. + + What keeps a `go` safe is not this number. Setpoints are streamed at + `--speed` so the arm moves continuously rather than lurching, `--max-lag` + stops it if the arm falls behind, and every move is confirmed unless + `--yes`. This is a sanity check on the arithmetic, not the guard. + """ + return max((j.max_rad - j.min_rad for j in cfg.joints), default=0.0) + + def _cmd_save(node: PoseClient, args) -> None: + """Capture the arm's pose, clamped into the band it can actually be sent to. + + Posing by hand means posing a limp arm against its mechanical stops, and the + soft limits sit a margin (0.05 rad) inside those, so a captured position is + routinely a fraction past the band. Stored raw, that pose can never be + reached: every `go` clamps it and says so, which is a warning the operator + receives minutes later and cannot act on. Stored clamped, it is reachable by + construction and differs from what was posed by at most the margin. + + A joint further out than the margin is a different matter -- it means the + arm and arm.yaml disagree about where that joint's limits are -- so it is + called out separately rather than folded into the same quiet clamp. + """ current = _require_states(node) - path = poses.save_pose(args.name, current) + clamped: dict[str, float] = {} + held: dict[str, float] = {} + suspect: list[str] = [] + for name, value in current.items(): + try: + joint = node.cfg.joint(name) + except KeyError: + clamped[name] = value + continue + clamped[name] = joint.clamp_rad(value) + if clamped[name] != value: + held[name] = value + # DEFAULT_MARGIN rather than the joint's own: arm.yaml records the + # margin per joint but JointSpec does not carry it, and the two + # differ only if someone passed --margin to arm-calibrate. + if abs(clamped[name] - value) > DEFAULT_MARGIN: + suspect.append(name) + + path = poses.save_pose(args.name, clamped) print(f"saved pose {args.name!r} to {path}") for name in node.cfg.names: - print(f" {name:<14} {current[name]:+.4f} rad") + note = ( + f" <- held at the soft limit, from {held[name]:+.4f}" + if name in held + else "" + ) + print(f" {name:<14} {clamped[name]:+.4f} rad{note}") + if held: + print( + f"\n{len(held)} joint(s) were posed past their soft limits and are " + "stored at the limit, so this pose is reachable as saved. Posing a " + "limp arm against its stops does this: the soft limits sit a margin " + "inside them." + ) + if suspect: + print( + f"\n{', '.join(suspect)}: further out than the calibration margin, " + "so the arm and $MOTE_HOME/arm.yaml disagree about this joint's " + "limits. `pixi run arm-calibrate` re-measures them." + ) def _cmd_list(node: PoseClient, args) -> None: @@ -185,11 +263,18 @@ def _cmd_go(node: PoseClient, args) -> None: goals[joint_name] = clamped print(f"largest single-joint travel: {largest:.4f} rad") - if largest > args.max_travel: + ceiling = args.max_travel + if ceiling is None: + ceiling = widest_travel(node.cfg) + if largest > ceiling: raise SystemExit( - f"refusing: travel {largest:.4f} rad exceeds --max-travel " - f"{args.max_travel:.4f}. Re-run with a larger --max-travel if that " - "is genuinely intended." + f"refusing: travel {largest:.4f} rad exceeds {ceiling:.4f}, the " + "widest travel any joint on this arm has. Something disagrees about " + "the frame — check `pixi run arm-pose list` and arm.yaml." + if args.max_travel is None + else f"refusing: travel {largest:.4f} rad exceeds --max-travel " + f"{ceiling:.4f}. Re-run with a larger --max-travel if that is " + "genuinely intended." ) if not args.yes: @@ -302,8 +387,11 @@ def build_parser() -> argparse.ArgumentParser: p_go.add_argument( "--max-travel", type=float, - default=0.35, - help="refuse if any joint would move more than this many rad (default 0.35)", + default=None, + help="refuse if any joint would move more than this many rad. Defaults " + "to the widest travel any joint on this arm has, so it fires only on a " + "move that is impossible rather than merely large; pass a smaller one " + "to keep a bench run tight.", ) p_go.add_argument( "--speed", diff --git a/mote_arm/test/test_arm_pose_reach.py b/mote_arm/test/test_arm_pose_reach.py new file mode 100644 index 0000000..b47c068 --- /dev/null +++ b/mote_arm/test/test_arm_pose_reach.py @@ -0,0 +1,77 @@ +"""A taught pose the arm cannot be sent to, and the ceiling on how far `go` moves. + +Both were found at the bench in one sitting. Posing by hand means posing a limp +arm against its mechanical stops, and the soft limits sit a margin inside those, +so a captured position is routinely a fraction past the band — stored raw, the +pose can never be reached and every `go` clamps it and says so, minutes later, +when nothing can be done about it. And `--max-travel` defaulted to 0.35 rad, +chosen when the packaged limits were the old pose-envelope output whose bands +were ~0.2 rad; against real calibrated ~3.5 rad joints it refused the ordinary +move, which is teach a pose, let go, watch the limp arm fall to rest, replay. +""" + +import pytest + +from mote_arm.arm_pose import widest_travel +from mote_arm.config import ArmConfig, JointSpec, ServoGains + + +def cfg(*joints): + return ArmConfig( + port="/dev/fake", + baud_rate=1000000, + gains=ServoGains(64, 32, 0), + joints=list(joints), + ) + + +def joint(name, low, high, invert=False): + return JointSpec(name=name, id=1, min_rad=low, max_rad=high, invert=invert) + + +def test_the_ceiling_is_the_widest_travel_the_arm_has(): + arm = cfg(joint("a", -1.7785, 1.7785), joint("b", -2.0331, 2.0331)) + assert widest_travel(arm) == pytest.approx(4.0662) + + +def test_the_ceiling_admits_the_move_that_the_old_default_refused(): + """3.33 rad, elbow_flex, from the rest position to a taught `reachy`.""" + arm = cfg(joint("elbow_flex", -1.6458, 1.6458), joint("pan", -2.0331, 2.0331)) + assert widest_travel(arm) > 3.3255 + assert 0.35 < 3.3255 # the number it replaces refused it + + +def test_an_asymmetric_band_is_measured_end_to_end_not_from_zero(): + assert widest_travel(cfg(joint("a", -1.0, 0.5))) == pytest.approx(1.5) + + +def test_an_armless_config_has_no_travel(): + assert widest_travel(cfg()) == 0.0 + + +# --- what `save` stores ------------------------------------------------------ + + +def clamp_pose(arm, captured): + """What _cmd_save writes: every joint held inside its own soft band.""" + return {n: arm.joint(n).clamp_rad(v) for n, v in captured.items()} + + +def test_a_pose_captured_against_a_stop_is_stored_at_the_limit(): + arm = cfg(joint("elbow_flex", -1.6458, 1.6458)) + # Measured at the bench: hand-posed to the stop, a hair past the soft limit. + stored = clamp_pose(arm, {"elbow_flex": -1.6582}) + assert stored["elbow_flex"] == pytest.approx(-1.6458) + + +def test_a_stored_pose_is_reachable_by_construction(): + """The property: `go` clamps nothing, because `save` already did.""" + arm = cfg(joint("elbow_flex", -1.6458, 1.6458), joint("gripper", -1.0729, 1.0729)) + stored = clamp_pose(arm, {"elbow_flex": -1.6582, "gripper": -1.1152}) + for name, value in stored.items(): + assert arm.joint(name).clamp_rad(value) == value + + +def test_a_pose_inside_the_band_is_stored_untouched(): + arm = cfg(joint("elbow_flex", -1.6458, 1.6458)) + assert clamp_pose(arm, {"elbow_flex": 0.25})["elbow_flex"] == 0.25 From 6776d0864f4cf4bd95fec68e01a59637e17fe87b Mon Sep 17 00:00:00 2001 From: Michael Johnson Date: Thu, 3 Sep 2026 11:15:34 +0100 Subject: [PATCH 14/22] arm-pose go: delete the travel ceiling rather than resize it The previous commit sized --max-travel off the arm so it would fire only on an impossible move. That is a flag, a help string, a test and a paragraph of documentation that all describe nothing, which is worse than the fossil they replaced. Removed. Distance is not what makes a move risky once setpoints are streamed. The arm advances at --speed whatever the distance, so a long move is a slow move and not a violent one; --max-lag stops it if the arm falls behind; the soft limits bound the destination; and every move is confirmed unless --yes. The distance limit was a proxy for the lurch that streaming removed, and nobody removed the proxy. 0.35 rad then survived calibration widening the joints from the ~0.2 rad pose-envelope bands to their real ~3.5 rad ones, at which point it refused the ordinary case: teach a pose, let go, watch the limp arm fall to rest, replay. `go` still prints the travel each joint will make and still asks. episode-replay keeps its own --max-travel, and its help now says why: there a long approach means the arm is not where the recording started, so the replay will not reproduce it. That is a check on the episode, not on the motion, and "move it closer" only makes sense read that way. arm-pose go has no expectation about where the arm starts, so it has nothing to check. test_cli.py pinned cli.parse's strictness against `go --max-travel`; it now uses `--max-lag`, which is the same kind of flag and still exists. 250 tests pass. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_017hfWyNtkVh16hyvCK8K9cd --- CLAUDE.md | 18 ++++++++- mote_arm/BENCH.md | 5 ++- mote_arm/README.md | 36 ++++++++++------- mote_arm/TELEOP.md | 5 ++- mote_arm/mote_arm/arm_pose.py | 58 ++++++---------------------- mote_arm/mote_arm/episode_replay.py | 6 ++- mote_arm/test/test_arm_pose_reach.py | 30 ++------------ mote_arm/test/test_cli.py | 6 +-- 8 files changed, 68 insertions(+), 96 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 0430c24..5adc87d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -876,8 +876,22 @@ section. Contains: exception. - `poses.py` + `arm_pose` (`pixi run arm-pose`) — teach/replay named poses (`~/.mote/arm_poses.yaml`, `MOTE_HOME`-overridable), the arm's analogue of - `save-zone`. `go` refuses moves over `--max-travel`. Changing `home` - invalidates stored poses. `arm-pose limits` is **not** the calibration path: + `save-zone`. **`save` stores each joint clamped into its soft band** and names + the ones it held there: posing by hand is posing a limp arm against its + mechanical stops and the soft limits sit `--margin` inside those, so a raw + capture is routinely outside the band and could never be replayed (measured: + elbow_flex 0.012 rad past, gripper 0.042). A joint further out than the margin + is reported separately — that means the arm and arm.yaml disagree. **`go` has + no travel ceiling**: `--max-travel` (0.35 rad) was set when the packaged bands + were the ~0.2 rad pose-envelope output, and against real ~3.5 rad calibrated + joints it refused the ordinary case — teach, let go, watch the limp arm fall + to rest, replay. Sizing it off the arm would have made it fire on nothing + again, and distance is not what makes a move risky once setpoints are + streamed: `--speed` bounds the rate whatever the distance, `--max-lag` stops a + lagging arm, the soft limits bound the destination, and every move is + confirmed. `episode-replay` keeps its own `--max-travel` for a different job — + a long approach means the arm is not where the recording started. Changing + `home` invalidates stored poses. `arm-pose limits` is **not** the calibration path: it widens outward from taught poses, so it only describes where the arm has been and never finds the stops (which is why the committed limits give barely moved joints a near-zero band) — its remaining use is *narrowing* to a working diff --git a/mote_arm/BENCH.md b/mote_arm/BENCH.md index 188ef42..21a3071 100644 --- a/mote_arm/BENCH.md +++ b/mote_arm/BENCH.md @@ -296,8 +296,9 @@ came from Step 3. `pixi run arm-pose save ` (read-only capture). 3. Repeat for each pose worth reaching. 4. `pixi run arm-pose go ` moves between taught poses. It prints per-joint - travel and asks before moving; it refuses any move over `--max-travel` - (0.35 rad default). + travel and asks before moving. Expect a large travel on the first `go` after + a `save`: the arm is limp until a controller claims it, so it falls to rest + the moment you let go of it. `pixi run arm-pose limits` prints a `joints:` block spanning every taught pose plus a margin. It is **not** the calibration path — it widens outward from poses diff --git a/mote_arm/README.md b/mote_arm/README.md index c169b1e..214b47a 100644 --- a/mote_arm/README.md +++ b/mote_arm/README.md @@ -474,10 +474,9 @@ The values committed in `robot.yaml` today still come from the old envelope method, and are flagged as such in that file, pending a calibration pass on the real arm (`BENCH.md` step 3). -`arm-pose go` refuses any move whose largest single-joint travel exceeds -`--max-travel` (0.35 rad by default), so a stale pose or a bad limit change -cannot turn into a large unexpected swing. Raise it deliberately for a known-long -move (`--max-travel 4.0` for the full `home` <-> `reachy` swing). +`arm-pose go` prints the travel each joint will make and asks before moving. +There is no ceiling on the distance — see below for why the one there used to be +was removed. It **streams** setpoints at 20 Hz rather than commanding the destination in one jump, so the arm moves continuously at `--speed` (0.5 rad/s default) instead of @@ -506,16 +505,25 @@ warning that arrives minutes late. A joint further out than the margin is reported separately, because that means the arm and `arm.yaml` disagree about where the limits are rather than that the operator leaned on a stop. -**`go`'s travel ceiling is sized off the arm.** `--max-travel` defaults to the -widest travel any joint has, so it refuses an impossible move rather than a -merely large one. It was 0.35 rad, chosen when the packaged limits were the old -`arm-pose limits` envelope whose bands were ~0.2 rad — wider than a whole -joint's range, so it fired on nothing. Calibration gave the joints their real -~3.5 rad bands and left the guard refusing the ordinary case: teach a pose, let -go, watch the limp arm fall to rest, replay. What keeps a `go` safe is not that -number — setpoints are streamed at `--speed` so the arm moves continuously -rather than lurching, `--max-lag` stops it if the arm falls behind, and every -move is confirmed unless `--yes`. +**`go` has no travel ceiling, and the one it had is gone.** `--max-travel` +defaulted to 0.35 rad, chosen when the packaged limits were the old `arm-pose +limits` envelope whose bands were ~0.2 rad — wider than a whole joint's range, +so it fired on nothing. Calibration gave the joints their real ~3.5 rad bands +and left it refusing the ordinary case: teach a pose, let go, watch the limp arm +fall to rest, replay. + +Sizing it off the arm instead would have made it fire on nothing again, which is +worse than removing it: a flag, a help string and a test that all describe +nothing. And distance is not what makes a move risky once setpoints are +streamed. The arm advances at `--speed` whatever the distance, so a long move is +a slow move and not a violent one; `--max-lag` stops it if the arm falls behind; +the soft limits bound where it can go; and every move is confirmed unless +`--yes`. The distance limit was a proxy for the lurch that streaming removed, +and nobody removed the proxy. + +`episode-replay` keeps a `--max-travel`, doing a different job: a long approach +there means the arm is not where the recording started, so the replay will not +reproduce it. That is a check on the episode, not on the motion. ## Torque policy diff --git a/mote_arm/TELEOP.md b/mote_arm/TELEOP.md index ad6232d..829b0a3 100644 --- a/mote_arm/TELEOP.md +++ b/mote_arm/TELEOP.md @@ -208,7 +208,10 @@ off-board. Three gates, in order: 1. **Reduced speed** — actions are issued at `fps * --speed-scale`, a quarter of the recorded rate by default. The same path, not the same dynamics. 2. **Approach, then replay** — the arm is walked to the episode's first pose - first, and the move is refused if that is further than `--max-travel`. + first, and refused if it starts further away than `--max-travel`. That is a + check on the recording, not on the motion: a replay begun from somewhere the + episode never saw will not reproduce it. (`arm-pose go` has no such limit — + it has no expectation about where the arm starts.) 3. **Lag supervision** — the rule that guards `arm-pose go`: if the arm trails its setpoint for `--stall-time`, the replay stops where it is. diff --git a/mote_arm/mote_arm/arm_pose.py b/mote_arm/mote_arm/arm_pose.py index d4713bf..6d1b50f 100644 --- a/mote_arm/mote_arm/arm_pose.py +++ b/mote_arm/mote_arm/arm_pose.py @@ -16,11 +16,18 @@ and could never be replayed. ``go`` is the only command that moves the arm, and it leaves the arm *holding* the pose it reached (deactivate ``arm_controller``, or run ``arm-jog`` and ``torque off``, to make it limp again): it reports the -distance each joint will travel, requires confirmation unless ``--yes`` is -given, and refuses moves whose largest single-joint travel exceeds -``--max-travel`` — by default the widest travel any joint on this arm has, so it -fires on an impossible move rather than a merely large one. Goals are clamped to -the soft limits here *and* in the driver. +distance each joint will travel and requires confirmation unless ``--yes`` is +given. Goals are clamped to the soft limits here *and* in the driver. + +There is no ceiling on how far a `go` may move. Distance is not what makes a +move risky once setpoints are streamed: the arm advances at ``--speed`` +whatever the distance, so a long move is a slow move rather than a violent one, +and ``--max-lag`` stops it if the arm falls behind. A distance limit was a proxy +for the lurch that streaming removed, and with real calibrated joints it refused +the ordinary case — teach a pose, let go, watch the limp arm fall to rest, +replay. ``episode-replay`` keeps one for a different job: there a long approach +means the arm is not where the recording starts, so the replay will not +reproduce. The arm is limp whenever no controller holds it, so it falls to rest the moment you let go of it. A `go` straight after a `save` therefore starts from the rest @@ -94,25 +101,6 @@ def _require_states(node: PoseClient) -> dict[str, float]: return node.current() -def widest_travel(cfg) -> float: - """The largest distance any one joint on this arm can legally be sent. - - The default ceiling for `go`. The 0.35 rad it replaces was chosen when the - packaged limits were the old `arm-pose limits` envelope, whose bands were - ~0.2 rad -- so 0.35 was wider than a whole joint's configured range and - fired on nothing. Calibration then gave the joints their real ~3.5 rad - bands and left the guard firing on almost every real move, including the - ordinary one: teach a pose, let go of a limp arm, watch it fall to rest, - replay. Sized off the arm, it fires only on a move that is impossible. - - What keeps a `go` safe is not this number. Setpoints are streamed at - `--speed` so the arm moves continuously rather than lurching, `--max-lag` - stops it if the arm falls behind, and every move is confirmed unless - `--yes`. This is a sanity check on the arithmetic, not the guard. - """ - return max((j.max_rad - j.min_rad for j in cfg.joints), default=0.0) - - def _cmd_save(node: PoseClient, args) -> None: """Capture the arm's pose, clamped into the band it can actually be sent to. @@ -263,19 +251,6 @@ def _cmd_go(node: PoseClient, args) -> None: goals[joint_name] = clamped print(f"largest single-joint travel: {largest:.4f} rad") - ceiling = args.max_travel - if ceiling is None: - ceiling = widest_travel(node.cfg) - if largest > ceiling: - raise SystemExit( - f"refusing: travel {largest:.4f} rad exceeds {ceiling:.4f}, the " - "widest travel any joint on this arm has. Something disagrees about " - "the frame — check `pixi run arm-pose list` and arm.yaml." - if args.max_travel is None - else f"refusing: travel {largest:.4f} rad exceeds --max-travel " - f"{ceiling:.4f}. Re-run with a larger --max-travel if that is " - "genuinely intended." - ) if not args.yes: reply = input("proceed? [y/N] ").strip().lower() @@ -384,15 +359,6 @@ def build_parser() -> argparse.ArgumentParser: p_go = sub.add_parser("go", help="move to a taught pose") p_go.add_argument("name") p_go.add_argument("--yes", action="store_true", help="skip confirmation") - p_go.add_argument( - "--max-travel", - type=float, - default=None, - help="refuse if any joint would move more than this many rad. Defaults " - "to the widest travel any joint on this arm has, so it fires only on a " - "move that is impossible rather than merely large; pass a smaller one " - "to keep a bench run tight.", - ) p_go.add_argument( "--speed", type=float, diff --git a/mote_arm/mote_arm/episode_replay.py b/mote_arm/mote_arm/episode_replay.py index 39fd152..4846998 100644 --- a/mote_arm/mote_arm/episode_replay.py +++ b/mote_arm/mote_arm/episode_replay.py @@ -239,7 +239,11 @@ def main() -> None: "--max-travel", type=float, default=0.35, - help="refuse if the approach would move a joint further than this (default 0.35)", + help="refuse if the arm starts further than this from the episode's " + "first pose (default 0.35). Not a limit on how fast or how far the arm " + "may move -- --speed and --max-lag govern that -- but a check that the " + "arm is where the recording began, since a replay from somewhere else " + "will not reproduce it.", ) parser.add_argument("--max-lag", type=float, default=0.15) parser.add_argument("--stall-time", type=float, default=1.5) diff --git a/mote_arm/test/test_arm_pose_reach.py b/mote_arm/test/test_arm_pose_reach.py index b47c068..4a54e60 100644 --- a/mote_arm/test/test_arm_pose_reach.py +++ b/mote_arm/test/test_arm_pose_reach.py @@ -1,18 +1,14 @@ -"""A taught pose the arm cannot be sent to, and the ceiling on how far `go` moves. +"""A taught pose the arm cannot be sent to. -Both were found at the bench in one sitting. Posing by hand means posing a limp +Found at the bench. Posing by hand means posing a limp arm against its mechanical stops, and the soft limits sit a margin inside those, so a captured position is routinely a fraction past the band — stored raw, the pose can never be reached and every `go` clamps it and says so, minutes later, -when nothing can be done about it. And `--max-travel` defaulted to 0.35 rad, -chosen when the packaged limits were the old pose-envelope output whose bands -were ~0.2 rad; against real calibrated ~3.5 rad joints it refused the ordinary -move, which is teach a pose, let go, watch the limp arm fall to rest, replay. +when nothing can be done about it. """ import pytest -from mote_arm.arm_pose import widest_travel from mote_arm.config import ArmConfig, JointSpec, ServoGains @@ -29,26 +25,6 @@ def joint(name, low, high, invert=False): return JointSpec(name=name, id=1, min_rad=low, max_rad=high, invert=invert) -def test_the_ceiling_is_the_widest_travel_the_arm_has(): - arm = cfg(joint("a", -1.7785, 1.7785), joint("b", -2.0331, 2.0331)) - assert widest_travel(arm) == pytest.approx(4.0662) - - -def test_the_ceiling_admits_the_move_that_the_old_default_refused(): - """3.33 rad, elbow_flex, from the rest position to a taught `reachy`.""" - arm = cfg(joint("elbow_flex", -1.6458, 1.6458), joint("pan", -2.0331, 2.0331)) - assert widest_travel(arm) > 3.3255 - assert 0.35 < 3.3255 # the number it replaces refused it - - -def test_an_asymmetric_band_is_measured_end_to_end_not_from_zero(): - assert widest_travel(cfg(joint("a", -1.0, 0.5))) == pytest.approx(1.5) - - -def test_an_armless_config_has_no_travel(): - assert widest_travel(cfg()) == 0.0 - - # --- what `save` stores ------------------------------------------------------ diff --git a/mote_arm/test/test_cli.py b/mote_arm/test/test_cli.py index bf0543f..8f5bd03 100644 --- a/mote_arm/test/test_cli.py +++ b/mote_arm/test/test_cli.py @@ -40,13 +40,13 @@ def test_no_ros_block_is_left_alone(): def test_parse_accepts_a_safety_flag_past_a_ros_block(): args = cli.parse( build_parser(), - ["go", "home", "--max-travel", "1.25", "--ros-args", "-p", "x:=1"], + ["go", "home", "--max-lag", "1.25", "--ros-args", "-p", "x:=1"], ) assert args.name == "home" - assert args.max_travel == 1.25 + assert args.max_lag == 1.25 -@pytest.mark.parametrize("flag", ["--max_travel", "--maxtravel", "--speeed"]) +@pytest.mark.parametrize("flag", ["--max_lag", "--maxlag", "--speeed"]) def test_a_mistyped_safety_flag_is_an_error(flag): """Not a warning, and above all not silence: the arm would move anyway.""" with pytest.raises(SystemExit) as exc: From 7cb0435a9852dfaa00f2a6f4e8d6463f511a21e3 Mon Sep 17 00:00:00 2001 From: Michael Johnson Date: Thu, 3 Sep 2026 11:35:28 +0100 Subject: [PATCH 15/22] Read whether the arm is held; drop arm-pose go's confirmation Two things, both found on the second `arm-pose go` of a bench session. ArmControl assumed `holding = False` at construction, which is a fact about the graph being guessed by a process that has just started. `go` leaves arm_controller active on purpose -- that is what holding the pose means -- so the next command client starts against an already-held arm, asks for a STRICT switch the controller manager refuses, gets False back from set_holding, and retries once per streamed setpoint: Controller with name 'arm_controller' is already active. Aborting, no controller is switched! (::STRICT switch) It now reads list_controllers before its first switch, and re-reads after a refusal rather than reporting failure: a STRICT switch refuses a controller already in the state asked for, which is the caller's success. The same assumption made `arm-jog`'s documented limp-on-exit silently do nothing when something else had left the arm holding -- `set_holding(False)` matched the assumed False and returned early. `mock_arm` answers list_controllers for the same reason it answers switch_controller, or every read waits out a 5 s timeout. And `go` no longer asks y/N. The move is bounded by --speed and supervised by --max-lag, the destination is a pose the operator taught and `save` has already clamped into the soft limits, and the travel ceiling that used to justify a second look is gone. A prompt on every bench move buys none of that, so `--yes` goes with it. episode-replay keeps its confirmation: that one starts an unattended replay of a whole recorded episode. 260 tests pass. test_control_holding.py drives ArmControl against fake service clients, so the state-assumption bug fails there rather than on the second `go` of a session. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_017hfWyNtkVh16hyvCK8K9cd --- CLAUDE.md | 11 ++ mote_arm/README.md | 11 ++ mote_arm/mote_arm/arm_pose.py | 14 +-- mote_arm/mote_arm/control.py | 62 ++++++++-- mote_arm/mote_arm/jog.py | 4 +- mote_arm/mote_arm/mock_arm.py | 22 +++- mote_arm/test/test_cli.py | 11 +- mote_arm/test/test_control_holding.py | 169 ++++++++++++++++++++++++++ 8 files changed, 279 insertions(+), 25 deletions(-) create mode 100644 mote_arm/test/test_control_holding.py diff --git a/CLAUDE.md b/CLAUDE.md index 5adc87d..4f0f233 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -701,6 +701,17 @@ section. Contains: (ROS-free, unit-tested in `test/`). - `bus.py` — `FeetechBus`, a thin `scservo_sdk` wrapper (lazy import so build/lint/test stay hardware-free); register map matches `mote_hardware`. +- **Whether the arm is held is read from the controller manager, never assumed** + (`control.py`: `ArmControl.active()` / `held`). `arm-pose go` leaves + `arm_controller` active — that *is* holding the pose — so the next command + client starts against an already-held arm; assuming `inactive` at construction + made the second `arm-pose go` of a session ask for a STRICT switch the manager + refuses (`Controller with name 'arm_controller' is already active` / + `Aborting, no controller is switched!`) once per streamed setpoint at 20 Hz, + and made `arm-jog`'s documented limp-on-exit silently do nothing. A refused + switch is re-read before being reported as a failure, since it means success + when the controller is already in the state asked for. `mock_arm` answers + `list_controllers` for the same reason it answers `switch_controller`. - **The arm is part of `mote_hardware`'s ros2_control component**, not a driver of its own: `MoteHardware` exports position command interfaces for the six arm joints alongside the wheels' velocity ones, from one `open()` of the shared diff --git a/mote_arm/README.md b/mote_arm/README.md index 214b47a..869b724 100644 --- a/mote_arm/README.md +++ b/mote_arm/README.md @@ -495,6 +495,17 @@ Lag is measured against `/joint_states`, which the hardware refreshes one arm joint per control cycle to stay inside the bus budget it shares with the wheels, so a stall is caught within a few setpoints rather than instantly. +**Whether the arm is held is read from the controller manager, not assumed.** +`go` leaves `arm_controller` active — that is what holding the pose means — so +the next command client starts against an arm that is already held. +`ArmControl` therefore asks `list_controllers` before its first switch, and +treats a STRICT refusal as success when the controller turns out to already be +in the state requested. Assuming `inactive` at construction made the *second* +`arm-pose go` of a session fail on every streamed setpoint: `Controller with +name 'arm_controller' is already active` / `Aborting, no controller is +switched!`, at 20 Hz. It also made `arm-jog`'s documented limp-on-exit silently +do nothing when something else had left the arm holding. + **A taught pose is stored reachable.** `save` clamps each joint into its soft band and names the ones it held there. Posing by hand is posing a limp arm against its stops, and the soft limits sit `--margin` (0.05 rad) inside those, diff --git a/mote_arm/mote_arm/arm_pose.py b/mote_arm/mote_arm/arm_pose.py index 6d1b50f..dc1a3ae 100644 --- a/mote_arm/mote_arm/arm_pose.py +++ b/mote_arm/mote_arm/arm_pose.py @@ -16,8 +16,11 @@ and could never be replayed. ``go`` is the only command that moves the arm, and it leaves the arm *holding* the pose it reached (deactivate ``arm_controller``, or run ``arm-jog`` and ``torque off``, to make it limp again): it reports the -distance each joint will travel and requires confirmation unless ``--yes`` is -given. Goals are clamped to the soft limits here *and* in the driver. +distance each joint will travel and then moves. There is no confirmation: the +move is bounded by ``--speed`` and supervised by ``--max-lag``, the destination +is a pose the operator taught and `save` already clamped into the soft limits, +and a prompt on every bench move is a keypress that buys none of that. Goals are +clamped to the soft limits here *and* in the driver. There is no ceiling on how far a `go` may move. Distance is not what makes a move risky once setpoints are streamed: the arm advances at ``--speed`` @@ -252,12 +255,6 @@ def _cmd_go(node: PoseClient, args) -> None: print(f"largest single-joint travel: {largest:.4f} rad") - if not args.yes: - reply = input("proceed? [y/N] ").strip().lower() - if reply not in ("y", "yes"): - print("aborted; nothing sent") - return - _stream(node, current, goals, args) final = node.current() @@ -358,7 +355,6 @@ def build_parser() -> argparse.ArgumentParser: p_go = sub.add_parser("go", help="move to a taught pose") p_go.add_argument("name") - p_go.add_argument("--yes", action="store_true", help="skip confirmation") p_go.add_argument( "--speed", type=float, diff --git a/mote_arm/mote_arm/control.py b/mote_arm/mote_arm/control.py index 9a29aa7..dbb7e1e 100644 --- a/mote_arm/mote_arm/control.py +++ b/mote_arm/mote_arm/control.py @@ -13,7 +13,11 @@ (MoteHardware::perform_command_mode_switch); releasing them drops torque. So the arm is limp whenever `arm_controller` is inactive, which is how it is spawned, and a client that wants to move the arm activates it first and - deactivates it on the way out. + deactivates it on the way out. **Whether it is active is read, never + assumed**: `arm-pose go` leaves the controller holding the pose it reached, so + the next command client starts against an arm that is already held, and a + process that assumed otherwise asked for a STRICT switch the controller + manager refuses — once per streamed setpoint. * **A goal is a single-point trajectory starting now.** Leaving the header stamp at zero means "start now" on the *robot's* clock, so an operator's clock never enters the motion path. @@ -24,12 +28,13 @@ import time from builtin_interfaces.msg import Duration -from controller_manager_msgs.srv import SwitchController +from controller_manager_msgs.srv import ListControllers, SwitchController from trajectory_msgs.msg import JointTrajectory, JointTrajectoryPoint ARM_CONTROLLER = "arm_controller" TRAJECTORY_TOPIC = f"{ARM_CONTROLLER}/joint_trajectory" SWITCH_SERVICE = "controller_manager/switch_controller" +LIST_SERVICE = "controller_manager/list_controllers" def duration_msg(seconds: float) -> Duration: @@ -64,7 +69,12 @@ def __init__(self, node): self._node = node self._pub = node.create_publisher(JointTrajectory, TRAJECTORY_TOPIC, 10) self._switch = node.create_client(SwitchController, SWITCH_SERVICE) - self.holding = False + self._list = node.create_client(ListControllers, LIST_SERVICE) + # None until asked. Whether the arm is held is a fact about the + # controller manager, not about this process: `arm-pose go` leaves + # the controller active on purpose, so the next command client + # starts against an arm that is already holding. + self.holding: bool | None = None def send(self, goals: dict[str, float], seconds: float) -> bool: """Command the given joints, taking hold of the arm first if it is limp.""" @@ -73,12 +83,21 @@ def send(self, goals: dict[str, float], seconds: float) -> bool: self._pub.publish(trajectory(goals, seconds)) return True + @property + def held(self) -> bool: + """Whether the arm is being held, asked of the graph the first time.""" + if self.holding is None: + self.holding = self.active() + return bool(self.holding) + def set_holding(self, hold: bool, timeout: float = 5.0) -> bool: """Activate (hold) or deactivate (limp) the arm controller. Returns False if the request could not be delivered — the caller must not then assume the arm is holding. """ + if self.holding is None: + self.holding = self.active(timeout) if hold == self.holding: return True if not self._switch.wait_for_service(timeout_sec=timeout): @@ -95,15 +114,42 @@ def set_holding(self, hold: bool, timeout: float = 5.0) -> bool: req.deactivate_controllers = [ARM_CONTROLLER] req.strictness = SwitchController.Request.STRICT - future = self._switch.call_async(req) - deadline = time.time() + timeout - while not future.done() and time.time() < deadline: - time.sleep(0.02) - result = future.result() if future.done() else None + result = self._call(self._switch, req, timeout) if result is None or not result.ok: + # A STRICT switch refuses a controller already in the state asked + # for, which is the caller's success. Re-read rather than assume + # either way: something else on the graph may have moved it. + if self.active(timeout) == hold: + self.holding = hold + return True self._node.get_logger().warn( f"failed to {'activate' if hold else 'deactivate'} {ARM_CONTROLLER}" ) return False self.holding = hold return True + + def active(self, timeout: float = 5.0) -> bool | None: + """Whether arm_controller is active, or None if that cannot be read.""" + if not self._list.wait_for_service(timeout_sec=timeout): + return None + result = self._call(self._list, ListControllers.Request(), timeout) + if result is None: + return None + for controller in result.controller: + if controller.name == ARM_CONTROLLER: + return controller.state == "active" + return None + + def _call(self, client, request, timeout: float): + """Await a service call on the executor spinning this node elsewhere. + + A sleep rather than a spin: these clients are used from a REPL or a + streaming loop on the main thread while `cli.spin_background` drives the + executor, and spinning here would be the second spinner. + """ + future = client.call_async(request) + deadline = time.time() + timeout + while not future.done() and time.time() < deadline: + time.sleep(0.02) + return future.result() if future.done() else None diff --git a/mote_arm/mote_arm/jog.py b/mote_arm/mote_arm/jog.py index 27abd9d..1f011ec 100644 --- a/mote_arm/mote_arm/jog.py +++ b/mote_arm/mote_arm/jog.py @@ -115,9 +115,7 @@ def send(self, joint: JointSpec, rad: float) -> None: def _print_status(node: JogClient, selected: int, step: float) -> None: - print( - f"\nstep = {step:.3f} rad arm is {'HOLDING' if node.arm.holding else 'LIMP'}" - ) + print(f"\nstep = {step:.3f} rad arm is {'HOLDING' if node.arm.held else 'LIMP'}") for i, joint in enumerate(node.cfg.joints): meas = node.measured(joint.name) meas_s = f"{meas:+.3f}" if meas is not None else " ? " diff --git a/mote_arm/mote_arm/mock_arm.py b/mote_arm/mote_arm/mock_arm.py index f54af40..de86381 100644 --- a/mote_arm/mote_arm/mock_arm.py +++ b/mote_arm/mote_arm/mock_arm.py @@ -33,13 +33,19 @@ import zlib import rclpy -from controller_manager_msgs.srv import SwitchController +from controller_manager_msgs.msg import ControllerState +from controller_manager_msgs.srv import ListControllers, SwitchController from rclpy.node import Node from sensor_msgs.msg import CompressedImage, JointState from trajectory_msgs.msg import JointTrajectory from mote_arm import cli, config -from mote_arm.control import ARM_CONTROLLER, SWITCH_SERVICE, TRAJECTORY_TOPIC +from mote_arm.control import ( + ARM_CONTROLLER, + LIST_SERVICE, + SWITCH_SERVICE, + TRAJECTORY_TOPIC, +) from mote_arm.motion import advance @@ -93,6 +99,10 @@ def __init__(self, args): JointTrajectory, TRAJECTORY_TOPIC, self._on_trajectory, 10 ) self.create_service(SwitchController, SWITCH_SERVICE, self._on_switch) + # A command client reads the controller's state rather than assuming + # it, so the mock has to answer that too or every read waits out a + # service timeout. + self.create_service(ListControllers, LIST_SERVICE, self._on_list) self._period = 1.0 / args.rate self.create_timer(self._period, self._tick) @@ -125,6 +135,14 @@ def _on_trajectory(self, msg: JointTrajectory) -> None: travel = abs(target - self.position[name]) self.rate[name] = min(self.args.speed, travel / max(seconds, self._period)) + def _on_list(self, _request, response): + state = ControllerState() + state.name = ARM_CONTROLLER + state.state = "active" if self.holding else "inactive" + state.type = "joint_trajectory_controller/JointTrajectoryController" + response.controller = [state] + return response + def _on_switch(self, request, response): if ARM_CONTROLLER in request.activate_controllers: self.holding = True diff --git a/mote_arm/test/test_cli.py b/mote_arm/test/test_cli.py index 8f5bd03..b5cbbaa 100644 --- a/mote_arm/test/test_cli.py +++ b/mote_arm/test/test_cli.py @@ -29,12 +29,17 @@ def test_bare_separator_closes_the_ros_block(): argparse would read that bare ``--`` as "no more options" and turn a flag after it into a positional it cannot place, so it must not survive. """ - argv = ["go", "home", "--ros-args", "-p", "x:=1", "--", "--yes"] - assert cli.user_args(argv) == ["go", "home", "--yes"] + argv = ["go", "home", "--ros-args", "-p", "x:=1", "--", "--max-lag", "0.2"] + assert cli.user_args(argv) == ["go", "home", "--max-lag", "0.2"] def test_no_ros_block_is_left_alone(): - assert cli.user_args(["go", "home", "--yes"]) == ["go", "home", "--yes"] + assert cli.user_args(["go", "home", "--max-lag", "0.2"]) == [ + "go", + "home", + "--max-lag", + "0.2", + ] def test_parse_accepts_a_safety_flag_past_a_ros_block(): diff --git a/mote_arm/test/test_control_holding.py b/mote_arm/test/test_control_holding.py new file mode 100644 index 0000000..3ecd7ac --- /dev/null +++ b/mote_arm/test/test_control_holding.py @@ -0,0 +1,169 @@ +"""Whether the arm is held is a fact about the graph, not about this process. + +`arm-pose go` leaves `arm_controller` active on purpose — it leaves the arm +holding the pose it reached — so the *next* command client starts against an arm +that is already holding. `ArmControl` used to assume `holding = False` at +construction, so that second process asked to activate an already-active +controller, the STRICT switch refused, `send` returned False, and the streaming +loop retried at 20 Hz: + + Controller with name 'arm_controller' is already active. + Aborting, no controller is switched! (::STRICT switch) + +Found at the bench on the second `arm-pose go` of a session. +""" + +from controller_manager_msgs.msg import ControllerState +from controller_manager_msgs.srv import ListControllers, SwitchController + +from mote_arm.control import ARM_CONTROLLER, LIST_SERVICE, SWITCH_SERVICE, ArmControl + + +class FakeFuture: + def __init__(self, result): + self._result = result + + def done(self): + return True + + def result(self): + return self._result + + +class FakeClient: + def __init__(self, answer, available=True): + self.answer = answer + self.available = available + self.requests = [] + + def wait_for_service(self, timeout_sec=None): + return self.available + + def call_async(self, request): + self.requests.append(request) + return FakeFuture(self.answer(request)) + + +class FakeLogger: + def __init__(self): + self.warnings = [] + + def warn(self, message): + self.warnings.append(message) + + +class FakeNode: + def __init__(self, clients): + self.clients = clients + self.logger = FakeLogger() + + def create_publisher(self, *_args, **_kw): + return self + + def create_client(self, _srv, name): + return self.clients[name] + + def get_logger(self): + return self.logger + + def publish(self, _msg): + pass + + +def listing(state: str | None): + def answer(_request): + response = ListControllers.Response() + if state is not None: + controller = ControllerState() + controller.name = ARM_CONTROLLER + controller.state = state + response.controller = [controller] + return response + + return answer + + +def switching(ok: bool): + def answer(_request): + response = SwitchController.Response() + response.ok = ok + return response + + return answer + + +def control(state="inactive", switch_ok=True, list_available=True): + clients = { + SWITCH_SERVICE: FakeClient(switching(switch_ok)), + LIST_SERVICE: FakeClient(listing(state), available=list_available), + } + node = FakeNode(clients) + return ArmControl(node), clients, node + + +def test_a_fresh_client_does_not_re_activate_an_already_active_controller(): + """The bug: a second `arm-pose go` in a session, refused every setpoint.""" + arm, clients, _ = control(state="active") + assert arm.set_holding(True) is True + assert clients[SWITCH_SERVICE].requests == [] + + +def test_a_fresh_client_activates_a_controller_that_is_inactive(): + arm, clients, _ = control(state="inactive") + assert arm.set_holding(True) is True + assert len(clients[SWITCH_SERVICE].requests) == 1 + assert clients[SWITCH_SERVICE].requests[0].activate_controllers == [ARM_CONTROLLER] + + +def test_a_fresh_client_deactivates_a_controller_another_process_left_holding(): + """`jog` says it limps on exit; assuming False meant it silently did not.""" + arm, clients, _ = control(state="active") + assert arm.set_holding(False) is True + assert clients[SWITCH_SERVICE].requests[0].deactivate_controllers == [ + ARM_CONTROLLER + ] + + +def test_a_refused_switch_is_success_when_the_state_is_already_right(): + """Something else may move the controller between the read and the switch.""" + arm, _, node = control(state="active", switch_ok=False) + arm.holding = False # a stale belief, as if seeded before that change + assert arm.set_holding(True) is True + assert node.logger.warnings == [] + + +def test_a_refused_switch_is_a_failure_when_the_state_is_still_wrong(): + arm, _, node = control(state="inactive", switch_ok=False) + assert arm.set_holding(True) is False + assert node.logger.warnings + + +def test_an_unreadable_state_still_attempts_the_switch(): + """No answer is not the same as `inactive`; try, rather than assume.""" + arm, clients, _ = control(state="active", list_available=False) + assert arm.active() is None + assert arm.set_holding(True) is True + assert len(clients[SWITCH_SERVICE].requests) == 1 + + +def test_a_controller_missing_from_the_listing_reads_as_unknown(): + arm, _, _ = control(state=None) + assert arm.active() is None + + +def test_held_asks_once_and_then_remembers(): + arm, clients, _ = control(state="active") + assert arm.held is True + assert arm.held is True + assert len(clients[LIST_SERVICE].requests) == 1 + + +def test_send_takes_hold_before_it_publishes(): + arm, clients, _ = control(state="inactive") + assert arm.send({"elbow_flex": 0.1}, 1.0) is True + assert clients[SWITCH_SERVICE].requests[0].activate_controllers == [ARM_CONTROLLER] + + +def test_send_reports_failure_rather_than_publishing_into_a_limp_arm(): + arm, _, _ = control(state="inactive", switch_ok=False) + assert arm.send({"elbow_flex": 0.1}, 1.0) is False From 18694582ba379213772520e5893f5dc8cf12d43c Mon Sep 17 00:00:00 2001 From: Michael Johnson Date: Thu, 3 Sep 2026 11:59:49 +0100 Subject: [PATCH 16/22] One `mirror:=` switch, so teleop does not cost a terminal for wanting a camera `arm_launch.py` had a `mirror:=` argument and `mote_launch.py` did not, which is why the bench session asked for four terminals: a run needing the camera has to be `pixi run launch`, and the mirror then had a window of its own purely because the two launch files disagreed. `pixi run launch mirror:=true` now folds it in exactly as `pixi run arm mirror:=true` does, and the bench is three terminals -- the robot, the leader you drive, and the script that asks. The declaration and the node live once in `launch_utils` (`declare_mirror_arg`, `arm_mirror_node`) rather than being copied into the second file, since the whole defect was two copies of one idea drifting. Still off by default in both: `arm-jog`, `arm-pose` and episode replay all command `arm_controller`, and none of them wants a second thing driving the arm in the same graph. Standalone `pixi run arm-mirror` stays, and TELEOP.md now says what it is for: beside a running mission, where `pixi run robot`/`mapping` already owns the bus and takes no such switch. 277 tests pass; both launch files were generated to confirm the argument and the node land exactly once in each. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_017hfWyNtkVh16hyvCK8K9cd --- CLAUDE.md | 5 ++- mote_arm/TELEOP.md | 15 +++++---- mote_arm/tools/bench_teleop.sh | 13 ++++---- mote_bringup/launch/arm_launch.py | 24 +++----------- mote_bringup/launch/mote_launch.py | 4 +++ mote_bringup/mote_bringup/launch_utils.py | 39 ++++++++++++++++++++++- mote_bringup/test/test_launch_utils.py | 23 +++++++++++++ 7 files changed, 89 insertions(+), 34 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 4f0f233..99cb057 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -934,7 +934,10 @@ section. Contains: - **Virtual-leader teleop + episode recording** (`mote_arm/TELEOP.md`) — teleop with **no leader arm**: a leader pose held in software, moved by the keyboard (`virtual_leader`, `pixi run arm-teleop`), published on `leader/joint_states`, - which `arm_mirror` (`pixi run arm-mirror`, or `pixi run arm mirror:=true`) + which `arm_mirror` (`pixi run arm mirror:=true`, or `pixi run launch + mirror:=true` when the camera is wanted too — one switch on both, so teleop + never costs a terminal for wanting a camera; `pixi run arm-mirror` standalone + is for beside a running mission, which takes no such switch) turns into `arm_controller` trajectories through `control.py`, like every other command client. **The frontend is deliberately replaceable** — the mirror's whole contract is `leader/joint_states` + a latched `teleop/estop`, diff --git a/mote_arm/TELEOP.md b/mote_arm/TELEOP.md index 829b0a3..77bd437 100644 --- a/mote_arm/TELEOP.md +++ b/mote_arm/TELEOP.md @@ -86,16 +86,19 @@ is how you should rehearse it — see [Without hardware](#without-hardware). ### 1. Driver and mirror ```bash -pixi run arm mirror:=true +pixi run arm mirror:=true # bench: controllers only +pixi run launch mirror:=true # the whole base, when you need the camera ``` -`mirror:=true` starts `arm_mirror` alongside the control stack. It is off by -default because `arm-jog`, `arm-pose` and replay all command the same -`arm_controller`, and none of them wants a second thing driving the arm in the -same graph. +`mirror:=true` starts `arm_mirror` alongside the control stack — the same switch +on both, so teleop never costs a terminal just because you also wanted the +camera. It is off by default because `arm-jog`, `arm-pose` and replay all +command the same `arm_controller`, and none of them wants a second thing driving +the arm in the same graph. During a mission the arm is already up (`pixi run robot` / `mapping` owns the -bus), so teleop there is just `pixi run arm-mirror` beside it. +bus) and neither takes the switch, so teleop there is `pixi run arm-mirror` +beside it. That is the one case where it is a process of its own. ### 2. Teleop diff --git a/mote_arm/tools/bench_teleop.sh b/mote_arm/tools/bench_teleop.sh index 80e20c7..8845bd3 100755 --- a/mote_arm/tools/bench_teleop.sh +++ b/mote_arm/tools/bench_teleop.sh @@ -6,14 +6,13 @@ # same loop headless against the mock follower. Run that first — this script # assumes the software already works and is here to check the *arm* does. # -# Four terminals. The first two are the robot, the third is what you drive, and -# the fourth is this script: +# Three terminals. The first is the robot, the second is what you drive, and +# the third is this script: # -# 1. pixi run launch base: controllers (arm included) + camera -# or, with no camera needed: pixi run arm mirror:=true, which folds in 2 -# 2. pixi run arm-mirror leader pose -> arm_controller -# 3. pixi run arm-teleop the virtual leader — YOU DRIVE THIS ONE -# 4. pixi run arm-bench-teleop <- this script: asks, records, replays +# 1. pixi run launch mirror:=true base + camera + the teleop mirror +# (`pixi run arm mirror:=true` is the same thing without lidar/camera) +# 2. pixi run arm-teleop the virtual leader — YOU DRIVE THIS ONE +# 3. pixi run arm-bench-teleop <- this script: asks, records, replays # # It writes a report you can paste into the task; nothing is recorded as passing # that you did not say you saw. diff --git a/mote_bringup/launch/arm_launch.py b/mote_bringup/launch/arm_launch.py index 920a1a6..4387747 100644 --- a/mote_bringup/launch/arm_launch.py +++ b/mote_bringup/launch/arm_launch.py @@ -24,9 +24,7 @@ import yaml from ament_index_python.packages import get_package_share_directory from launch import LaunchDescription -from launch.actions import DeclareLaunchArgument -from launch.conditions import IfCondition -from launch.substitutions import Command, LaunchConfiguration +from launch.substitutions import Command from launch_ros.actions import Node, SetParameter from launch_ros.parameter_descriptions import ParameterValue @@ -34,7 +32,9 @@ from mote_bringup.launch_utils import ( INACTIVE_CONTROLLERS, arm_config_file, + arm_mirror_node, controller_spawn_handler, + declare_mirror_arg, joint_params_file, resolved_arm, ) @@ -84,12 +84,7 @@ def generate_launch_description(): return LaunchDescription( [ - DeclareLaunchArgument( - "mirror", - default_value="false", - description="also run arm_mirror, for virtual-leader teleop " - "(see mote_arm/TELEOP.md)", - ), + declare_mirror_arg(), SetParameter(name="use_sim_time", value=False), robot_state_publisher, controller_manager, @@ -98,15 +93,6 @@ def generate_launch_description(): active=("joint_state_broadcaster",), inactive=INACTIVE_CONTROLLERS, ), - # Off by default: `arm-jog`, `arm-pose` and episode replay all - # command arm_controller too, and none of them wants a second - # thing driving the arm in the same graph. - Node( - package="mote_arm", - executable="arm_mirror", - name="arm_mirror", - output="screen", - condition=IfCondition(LaunchConfiguration("mirror")), - ), + arm_mirror_node(), ] ) diff --git a/mote_bringup/launch/mote_launch.py b/mote_bringup/launch/mote_launch.py index 8786315..695d68f 100644 --- a/mote_bringup/launch/mote_launch.py +++ b/mote_bringup/launch/mote_launch.py @@ -15,7 +15,9 @@ ICP_ODOM_FRAME, INACTIVE_CONTROLLERS, arm_config_file, + arm_mirror_node, controller_spawn_handler, + declare_mirror_arg, joint_params_file, resolved_arm, ) @@ -203,6 +205,7 @@ def generate_launch_description(): description="Run foxglove_bridge alongside the base. Set false " "when mote-foxglove.service already runs it.", ), + declare_mirror_arg(), SetParameter(name="use_sim_time", value=use_sim_time), robot_state_publisher, controller_manager, @@ -219,5 +222,6 @@ def generate_launch_description(): localization, twist_mux, foxglove, + arm_mirror_node(), ] ) diff --git a/mote_bringup/mote_bringup/launch_utils.py b/mote_bringup/mote_bringup/launch_utils.py index 56e96dd..8e9f5f5 100644 --- a/mote_bringup/mote_bringup/launch_utils.py +++ b/mote_bringup/mote_bringup/launch_utils.py @@ -7,8 +7,10 @@ import tempfile import yaml -from launch.actions import OpaqueFunction, RegisterEventHandler +from launch.actions import DeclareLaunchArgument, OpaqueFunction, RegisterEventHandler +from launch.conditions import IfCondition from launch.event_handlers import OnProcessStart +from launch.substitutions import LaunchConfiguration from launch_ros.actions import Node from mote_arm import config as arm_config @@ -39,6 +41,41 @@ INACTIVE_CONTROLLERS = ("arm_controller",) +MIRROR_ARG = "mirror" + + +def declare_mirror_arg(): + """The `mirror:=` switch, declared identically wherever the arm comes up. + + Off by default everywhere: `arm-jog`, `arm-pose` and episode replay all + command `arm_controller` too, and none of them wants a second thing driving + the arm in the same graph. + """ + return DeclareLaunchArgument( + MIRROR_ARG, + default_value="false", + description="also run arm_mirror, for virtual-leader teleop " + "(see mote_arm/TELEOP.md)", + ) + + +def arm_mirror_node(): + """`arm_mirror`, conditioned on `mirror:=`. + + Shared so that every way of bringing the arm up offers teleop the same way. + It was on `arm_launch.py` alone, which meant a session needing the camera + had to run `pixi run launch` and then the mirror in a terminal of its own — + a third window that existed only because two launch files disagreed. + """ + return Node( + package="mote_arm", + executable="arm_mirror", + name="arm_mirror", + output="screen", + condition=IfCondition(LaunchConfiguration(MIRROR_ARG)), + ) + + def arm_on_wheel_bus(cfg): """True when the arm is part of the wheel bus's ros2_control component. diff --git a/mote_bringup/test/test_launch_utils.py b/mote_bringup/test/test_launch_utils.py index 04dbfba..ca8184b 100644 --- a/mote_bringup/test/test_launch_utils.py +++ b/mote_bringup/test/test_launch_utils.py @@ -13,7 +13,10 @@ from mote_bringup.launch_utils import ( CONTROLLERS, + MIRROR_ARG, + arm_mirror_node, controller_spawn_handler, + declare_mirror_arg, spawn_controllers, ) @@ -67,3 +70,23 @@ def test_opaque_function_yields_fresh_spawners_on_repeated_execution(): assert all(isinstance(p, Node) for p in first + second) for a, b in zip(first, second): assert a is not b + + +def test_the_mirror_switch_is_declared_the_same_way_wherever_it_appears(): + """One definition, because two launch files disagreeing costs a terminal. + + `mirror:=` was on arm_launch.py alone, so a bench session needing the camera + ran `pixi run launch` and then arm_mirror in a window of its own — a third + terminal that existed only because the two files differed. + """ + arg = declare_mirror_arg() + assert arg.name == MIRROR_ARG == "mirror" + # Off by default: arm-jog, arm-pose and episode replay all command + # arm_controller too, and none wants a second thing driving the arm. + assert arg.default_value[0].text == "false" + + +def test_the_mirror_node_is_the_arm_package_s_own(): + node = arm_mirror_node() + assert node.node_package == "mote_arm" + assert node.node_executable == "arm_mirror" From d8b97b70e22f2a7c6fce8155cf16454d21a8195f Mon Sep 17 00:00:00 2001 From: Michael Johnson Date: Thu, 3 Sep 2026 12:02:20 +0100 Subject: [PATCH 17/22] bench-teleop: quitting the recorder is a verdict, not a traceback Pressing 'q' at the recorder's first prompt leaves no dataset.json, and `check_capture.py` read one before it reached its own "no episodes recorded" check -- so quitting the step produced a FileNotFoundError traceback under a FAIL line, which says that the check crashed and not that nothing was recorded. It now looks for the capture first and says what to do about it. The bench script then skips steps 5 and 6 rather than asking the operator to export and replay an episode that does not exist: two more FAILs would bury the one thing that actually went wrong. And step 3 now says which key starts a recording, since "ENTER to record episode, 'q' to finish" reads as a choice between two ways of proceeding. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_017hfWyNtkVh16hyvCK8K9cd --- mote_arm/test/teleop_loop/check_capture.py | 11 +++++++++++ mote_arm/tools/bench_teleop.sh | 16 ++++++++++++++++ 2 files changed, 27 insertions(+) diff --git a/mote_arm/test/teleop_loop/check_capture.py b/mote_arm/test/teleop_loop/check_capture.py index dbf8cdb..048c816 100755 --- a/mote_arm/test/teleop_loop/check_capture.py +++ b/mote_arm/test/teleop_loop/check_capture.py @@ -17,6 +17,17 @@ def main() -> int: capture = Path(sys.argv[1]) + # A recorder that was quit before its first episode leaves no dataset.json + # at all, so this runs before anything reads one: "you recorded nothing" is + # a verdict, and a traceback under it says only that the check crashed. + if not (capture / "dataset.json").exists(): + print( + f"nothing was recorded: {capture} holds no episodes.\n" + "Re-run and press ENTER at the record prompt to capture one, " + "driving the arm in the teleop terminal while it runs.", + file=sys.stderr, + ) + return 1 spec = load_dataset_spec(capture) episodes = list_episodes(capture) if not episodes: diff --git a/mote_arm/tools/bench_teleop.sh b/mote_arm/tools/bench_teleop.sh index 8845bd3..fff032e 100755 --- a/mote_arm/tools/bench_teleop.sh +++ b/mote_arm/tools/bench_teleop.sh @@ -116,15 +116,31 @@ check "press z to clear the latch, then drive again" \ rule "3. record an episode" echo "Drive a simple motion in the TELEOP terminal while this records." echo "The ENTER prompts below are read HERE, not there." +echo "Press ENTER to start the recording; 'q' ends the step, so pressing it" +echo "first leaves nothing to check, export or replay." ros2 run mote_arm episode_record --task "${TASK:-move the arm through a simple motion}" \ --dataset "$DATASET" --episodes 1 2>&1 | tee -a "$REPORT" rule "4. check the capture" if python3 "$HERE/../test/teleop_loop/check_capture.py" "$CAPTURE" 2>&1 | tee -a "$REPORT"; then note " PASS capture holds a real motion" + RECORDED=1 else note " FAIL capture check" FAILURES=$((FAILURES + 1)) + RECORDED=0 +fi + +# Steps 5 and 6 export and replay the episode step 3 recorded. With no episode +# they can only ask about work nobody can do, and a FAIL for each would bury +# the one thing that went wrong. +if [ "$RECORDED" -eq 0 ]; then + rule "5-6. export and replay" + note " SKIPPED there is no episode to export or replay" + rule "result" + note "$FAILURES check(s) failed. Record an episode in step 3 and re-run." + note "report: $REPORT" + exit 1 fi rule "5. export and inspect (off-board)" From 37ba485c957b9cb2f9ebca3e25784748195d3831 Mon Sep 17 00:00:00 2001 From: Michael Johnson Date: Thu, 3 Sep 2026 12:39:19 +0100 Subject: [PATCH 18/22] One `arm-setup` for everything that configures the servos arm-check, arm-calibrate, arm-gains, arm-offsets and arm-limits were five commands, and being five hid what they have in common: each opens /dev/mote_servos directly so the control stack has to be stopped first, and each writes servo EEPROM, which is per-robot hardware state with no copy in the repo. They are also all once-off except `check` -- run when the arm is built, a servo is swapped, or something is wrong -- which nothing in their names said. The arm is *driven* by arm-teleop, arm-pose and arm-record, which are arm_controller clients and never touch the bus. pixi run arm-setup check # read-only pixi run arm-setup calibrate # the once-off, needs a human pixi run arm-setup gains show|apply|sweep pixi run arm-setup offsets show|backup|restore|set pixi run arm-setup limits show|clear|restore Four things fall out of the merge rather than being the point of it. `bus.open_bus` replaces four byte-identical copies of the port guard -- three chances for one of them to grow a different idea of what "the base is running" means -- and it now names the port it is refusing to share. `--yes` and `--robot-yaml` are declared once, where they were on three of the five tools in two different places (arm_gains had --yes per subcommand). Every path now goes through `cli.parse`, which none of the five did: `ros2 run` hands a tool ROS's arguments too, and a mistyped `--jiont` was previously either fatal or silently dropped depending on which tool you were in. And the `--` separator is gone from the docs. It was needed because the first extra argument was a flag; now it is a subcommand. The five modules keep their names, their internals and their tests; only their `main()` becomes an `add_subparser`. 282 mote_arm tests and 17 mote_bringup tests pass, including a new table that pins every subcommand to the handler it used to reach, so the merge cannot quietly drop one. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_017hfWyNtkVh16hyvCK8K9cd --- CLAUDE.md | 34 ++++----- mote_arm/BENCH.md | 44 +++++------ mote_arm/README.md | 72 +++++++++--------- mote_arm/TELEOP.md | 2 +- mote_arm/mote_arm/arm_calibrate.py | 70 ++++++------------ mote_arm/mote_arm/arm_check.py | 89 ++++++++--------------- mote_arm/mote_arm/arm_gains.py | 53 +++----------- mote_arm/mote_arm/arm_limits.py | 66 ++++------------- mote_arm/mote_arm/arm_offsets.py | 74 ++++--------------- mote_arm/mote_arm/arm_pose.py | 4 +- mote_arm/mote_arm/arm_setup.py | 80 ++++++++++++++++++++ mote_arm/mote_arm/bus.py | 25 +++++++ mote_arm/mote_arm/calibrate.py | 2 +- mote_arm/mote_arm/config.py | 6 +- mote_arm/setup.py | 6 +- mote_arm/test/test_arm_setup.py | 78 ++++++++++++++++++++ mote_arm/test/test_calibrate.py | 2 +- mote_arm/test/test_calibrate_fences.py | 6 +- mote_arm/tools/bench_teleop.sh | 2 +- mote_bringup/mote_bringup/launch_utils.py | 2 +- pixi.toml | 23 ++---- 21 files changed, 367 insertions(+), 373 deletions(-) create mode 100644 mote_arm/mote_arm/arm_setup.py create mode 100644 mote_arm/test/test_arm_setup.py diff --git a/CLAUDE.md b/CLAUDE.md index 99cb057..37414fb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -24,9 +24,9 @@ pixi run explore # Autonomous mapping coverage (run beside `pixi run mapp pixi run tasks # Task layer: behaviour-tree task_server (see mote_tasks) pixi run arm # SO-101 arm: bench control stack (ros2_control, no mission) pixi run arm-jog # Interactive per-joint jog CLI (needs a stack owning the bus) -pixi run arm-check # Standalone arm bus enumeration + health (read-only, base stopped) -pixi run arm-calibrate # Range calibration: centre the joints, sweep, emit limits -pixi run arm-limits # Servo goal-range fence (EEPROM 9/11): show / clear / restore +pixi run arm-setup check # Standalone arm bus enumeration + health (read-only, base stopped) +pixi run arm-setup calibrate # Range calibration: centre the joints, sweep, emit limits +pixi run arm-setup limits # Servo goal-range fence (EEPROM 9/11): show / clear / restore pixi run arm-pose # Teach/replay named arm poses; narrow the envelope pixi run arm-teleop # Virtual-leader teleop: keyboard -> leader pose (mote_arm/TELEOP.md) pixi run arm-mirror # Mirror: leader pose -> clamped, rate-limited arm_controller goals @@ -747,16 +747,16 @@ section. Contains: usual workaround — silently discards a mistyped `--max-travel` or `--speed` and drives on the default. `test_cli.py` pins both, the abort via a child process's exit status since nothing in-process can catch it. -- `arm_check` (`pixi run arm-check`) — standalone read-only enumeration/health +- `arm_check` (`pixi run arm-setup check`) — standalone read-only enumeration/health + `--save-zero` calibration snapshot. Run with the driver stopped (same port). - **`zero` is not `home`.** `robot.yaml`'s `arm.joints[].zero` is the encoder count reading 0 rad — after calibration, the *middle* of the joint's travel. `home` is a taught *pose* in `~/.mote/arm_poses.yaml`, normally the arm's rest position. Both were spelled "home" until 2026-07-28 and it confused an operator at the bench, so the config key is `zero:` (`home:` still parses), - `jog`'s command is `zero` (`home` aliases it with a note), and `arm-check` has + `jog`'s command is `zero` (`home` aliases it with a note), and `arm-setup check` has `--save-zero`. Do not reintroduce the collision. -- `calibrate.py` + `arm_calibrate` (`pixi run arm-calibrate`) — **where the soft +- `calibrate.py` + `arm_calibrate` (`pixi run arm-setup calibrate`) — **where the soft limits come from**, in LeRobot's two phases. A bus owner, not a driver client (the driver reports radians about the very zero under replacement, and the arm must stay limp). **Phase 1** records every joint at once in one live table (not one at a time). @@ -819,9 +819,9 @@ section. Contains: encoder, not the joint) and such a joint simply reads `low` above `high`. `--skip-homing` re-measures ranges without writing anything. The maths is ROS-free and unit-tested (`test_calibrate.py`). -- `arm_offsets` (`pixi run arm-offsets show|backup|restore|set`) — the offset +- `arm_offsets` (`pixi run arm-setup offsets show|backup|restore|set`) — the offset register is the **only arm state with no copy outside the servo**, so - overwriting it destroys the previous value. `arm-calibrate` snapshots the + overwriting it destroys the previous value. `arm-setup calibrate` snapshots the existing offsets to `~/.mote/arm_offsets_backup.yaml` before its first write, writes/verifies/confirms each servo one at a time, and on any failure stops and points here — *including* a failure to save `arm.yaml` afterwards, which @@ -831,7 +831,7 @@ section. Contains: way back. **Servos can arrive with non-zero offsets** (this arm: 2027, -1723, 1772, -1706, -40, 1317), so the existing value is always read and folded in. -- `arm_limits` (`pixi run arm-limits show|clear|restore`) — **a fourth place a +- `arm_limits` (`pixi run arm-setup limits show|clear|restore`) — **a fourth place a limit can live, and the only one not in a file.** EEPROM registers 9 and 11 (`Min_Angle_Limit`/`Max_Angle_Limit`) fence which goals a servo accepts and refuse the rest **in silence**: no error, no status bit, no log line, so the @@ -842,7 +842,7 @@ section. Contains: against a configured -1.7785, at 0% load, with the command running 0.8 rad past it, and its `Min_Angle_Limit` read 1478 = -0.874 rad about zero 2048. Two properties hid it. The fence binds **only under torque**, so - `arm-calibrate` sweeps a limp joint straight through it and measures travel + `arm-setup calibrate` sweeps a limp joint straight through it and measures travel the arm will then refuse — the calibration and the arm disagree and only the arm is wrong. And the band is compared against the **corrected** goal, so moving a zero moves what it fences without changing any number a person can @@ -851,12 +851,12 @@ section. Contains: (`~/.cache/huggingface/lerobot/calibration/robots/so_follower/so101_follower.json`, whose `range_min`/`range_max` are those six bands to the count, beside the `homing_offset` values the servos arrived with). Writing them was reasonable; - **what broke is that `arm-calibrate` then moved the zeros on 2026-07-28 and + **what broke is that `arm-setup calibrate` then moved the zeros on 2026-07-28 and left the fence behind.** Two of the six were wrong even when written — `wrist_roll` unfenced because LeRobot hard-codes the SO-101's wrist_roll as full-turn and skips it, and `shoulder_pan` 760 counts short because its unwrapped min/max `record_ranges_of_motion` mis-records a wrap-crossing joint, - which shoulder_pan is. So **`arm-calibrate` now writes the fence and the zero + which shoulder_pan is. So **`arm-setup calibrate` now writes the fence and the zero in one run and never one without the other**: each joint's fence goes on immediately after its own offset — *after*, because the band is compared against the corrected goal and means the wrong angles until the frame has @@ -868,12 +868,12 @@ section. Contains: rather than correcting it. **The band written is the measured travel, not the soft limits** (`calibrate.fence_counts`) — wider by `--margin` at each end, so `arm.yaml` always binds first and the fence can never be what stops the arm in - ordinary use; `arm-limits show` reporting a band narrower than the configured + ordinary use; `arm-setup limits show` reporting a band narrower than the configured one therefore means something is wrong. What it backstops is a soft limit that has gone wrong — a hand-edited arm.yaml, a URDF that never received one, a servo swapped under a stale calibration. There is deliberately no - `arm-limits set`: a *narrower* envelope belongs in arm.yaml, where three - commands print it. `arm-check` reports the band beside the configured one. + `arm-setup limits set`: a *narrower* envelope belongs in arm.yaml, where three + commands print it. `arm-setup check` reports the band beside the configured one. - **Reads on this bus are hazardous twice over, and `FeetechBus._read` is the single choke point for both.** It clears the input buffer before every read, because a late reply is otherwise consumed as the answer to the *next* @@ -927,7 +927,7 @@ section. Contains: an arm ID colliding with a wheel ID is rejected in `config.py` *and* in `MoteHardware`, and both `MoteHardware::on_activate` and `mote_arm.bus` refuse a port another process already holds (naming the PID) — so the read-only bench - tools (`arm-check`, `arm-gains`), which still open the bus directly, need the + tools (`arm-setup check`, `arm-setup gains`), which still open the bus directly, need the control stack stopped (`pixi run kill`). `jog` and `arm-pose` do not. - Torque policy, control interfaces, and calibration in `mote_arm/README.md`; the human bench runbook in `mote_arm/BENCH.md`. @@ -977,7 +977,7 @@ section. Contains: nothing off-board; it approaches the first pose, replays at a quarter speed, and stops on sustained lag (`motion.py`, shared with `arm-pose go`). Stop the leader before replaying — two things commanding `arm_controller` fight. -- `arm_gains` (`pixi run arm-gains show|apply|sweep`) — the servos' position-loop +- `arm_gains` (`pixi run arm-setup gains show|apply|sweep`) — the servos' position-loop gains live in EEPROM, i.e. invisible config a servo swap would silently revert, so `robot.yaml`'s `arm.gains` is the source of truth and this tool reconciles hardware with it. The arm shipped `Kp=16`, which left permanent diff --git a/mote_arm/BENCH.md b/mote_arm/BENCH.md index 21a3071..1641c2a 100644 --- a/mote_arm/BENCH.md +++ b/mote_arm/BENCH.md @@ -45,14 +45,14 @@ needed. One process owns that port and it is the controller_manager, so the arm now comes up with the base rather than instead of it. Only one thing on this bench still needs the base stopped: the tools that open -the bus directly — `arm-check` and `arm-gains`. Run `pixi run kill` before +the bus directly — `arm-setup check` and `arm-setup gains`. Run `pixi run kill` before those; they refuse to start otherwise, naming the process that holds the port. `arm-jog` and `arm-pose` command the controller and need no such care. ## Step 2 — enumerate + health check ``` -pixi run arm-check +pixi run arm-setup check ``` **Expected:** a table with all six joints — `shoulder_pan`, `shoulder_lift`, @@ -73,7 +73,7 @@ falls. Stop the driver and the robot base first (`pixi run kill`): this tool opens the serial bus directly. ``` -pixi run arm-calibrate +pixi run arm-setup calibrate ``` ### Phase 1 — record the ranges @@ -122,14 +122,14 @@ we assume and would catch a wrong sign encoding. Success is a count rather than a list because a servo that fails stops the run by name, below. **If it stops partway** it names the servos already changed and points at -`pixi run arm-offsets restore`, which puts them back from the snapshot taken +`pixi run arm-setup offsets restore`, which puts them back from the snapshot taken before the first write. Do that before re-running. If a stop reports a position that looks nothing like the expected one, check whether the number it read equals the offset just written in sign-magnitude form (`abs(offset) | 0x800` for a negative one). That means the read picked up the previous register's reply rather than the position — the same -read-races-the-EEPROM-write hazard documented for `arm-gains`. Reads now clear +read-races-the-EEPROM-write hazard documented for `arm-setup gains`. Reads now clear the input buffer first and the post-write check requires two agreeing reads, so this should not recur; if it does, the settle delay needs raising further. @@ -145,7 +145,7 @@ ranges were on screen a moment ago, the limits are those pulled inward by **If the save fails** — validation rejects the document, or the file cannot be written — the servos have already been centred, so the arm is calibrated and the -file is not. It says so and names `pixi run arm-offsets restore`. Do one or the +file is not. It says so and names `pixi run arm-setup offsets restore`. Do one or the other before `pixi run arm`: until then the soft limits describe a frame the servos have stopped using. @@ -155,7 +155,7 @@ outside the new limits is named — that one was taught somewhere the arm cannot now reach and needs a decision. ``` -pixi run arm-check # rad column reads ~0.000 at the centred pose +pixi run arm-setup check # rad column reads ~0.000 at the centred pose ``` Nothing in the repo changes — the calibration is per-robot state under @@ -201,18 +201,18 @@ The as-found bands are snapshotted to `~/.mote/arm_limits_backup.yaml` before the first write, so: ``` -pixi run arm-limits show # read-only: the band, in counts and radians -pixi run arm-limits clear # hand the whole range back, outside a calibration -pixi run arm-limits restore # put the as-found bands back +pixi run arm-setup limits show # read-only: the band, in counts and radians +pixi run arm-setup limits clear # hand the whole range back, outside a calibration +pixi run arm-setup limits restore # put the as-found bands back ``` ### The offsets themselves ``` -pixi run arm-offsets show # read-only: raw register, decoded value, position -pixi run arm-offsets backup # snapshot before doing anything risky -pixi run arm-offsets restore # put the snapshot back -pixi run arm-offsets set --joint shoulder_pan --value=2027 +pixi run arm-setup offsets show # read-only: raw register, decoded value, position +pixi run arm-setup offsets backup # snapshot before doing anything risky +pixi run arm-setup offsets restore # put the snapshot back +pixi run arm-setup offsets set --joint shoulder_pan --value=2027 ``` `show` prints the raw register next to the decoded value deliberately: the @@ -310,15 +310,15 @@ over calibrated limits expecting to widen them: re-run Step 3 for that. ## Step 5c — measure the position-loop gains Gains live in servo EEPROM, so they are hardware config, not software config: -`robot.yaml`'s `arm.gains` records them and `arm-gains` reconciles the two. +`robot.yaml`'s `arm.gains` records them and `arm-setup gains` reconciles the two. Choose them from a measurement, not from a datasheet default. -Stop the driver first (`arm-gains` opens the bus itself) and clear the joint's +Stop the driver first (`arm-setup gains` opens the bus itself) and clear the joint's path — this step moves the arm. Park the arm in a pose it holds unsupported: each trial drops torque briefly to write the gains, so a raised pose would sag. -1. `pixi run arm-gains show` — what the servos actually hold right now. -2. `pixi run arm-gains sweep --joint elbow_flex --kp 16,32,64,128` — steps the +1. `pixi run arm-setup gains show` — what the servos actually hold right now. +2. `pixi run arm-setup gains sweep --joint elbow_flex --kp 16,32,64,128` — steps the joint -0.2 rad under each gain and prints error, load, settling, ripple and reversals per trial. **Expected:** error falls as `kp` rises while `kp*err` stays roughly constant and load stays far below 1000 (proportional droop); @@ -326,11 +326,11 @@ each trial drops torque briefly to write the gains, so a raised pose would sag. over a few counts, rising `rev`, or an audible buzz is the joint hunting, and that gain is too high whatever its error says. 3. Optional, for the residual droop: - `pixi run arm-gains sweep --joint elbow_flex --kp --ki 0,1,2`. + `pixi run arm-setup gains sweep --joint elbow_flex --kp --ki 0,1,2`. Do it with the joint **unloaded** first: integral action stores the effort it needed to hold a load, so removing that load can produce a lunge. 4. Put the winner in `robot.yaml`'s `arm.gains`, rebuild, then - `pixi run arm-gains apply` to write it to all six servos. + `pixi run arm-setup gains apply` to write it to all six servos. The sweep restores the gains it started with and leaves the joint limp, so a run on its own changes nothing — step 4 is what makes a choice stick. Each run writes @@ -419,12 +419,12 @@ Already verified on the robot (2026-07-25): - [x] enabling torque holds the current pose instead of snapping - [x] `min`/`max` in `robot.yaml` derived from taught poses, not guessed (superseded by the calibration pass below — provisional until it runs) -- [x] servo gains applied and verified (`pixi run arm-gains`), full +- [x] servo gains applied and verified (`pixi run arm-setup gains`), full home<->reachy move completed both ways - [x] gains chosen from a sweep, not a default: Kp=64 applied to all six, residual on the full move now 0.012-0.028 rad (2026-07-28) -- [x] **Step 3: one full `pixi run arm-calibrate` pass on the real arm** +- [x] **Step 3: one full `pixi run arm-setup calibrate` pass on the real arm** (2026-07-28), saved to `~/.mote/arm.yaml`; taught poses migrated automatically rather than re-taught - [x] every servo's homing offset written and confirmed, and no joint reports an diff --git a/mote_arm/README.md b/mote_arm/README.md index 869b724..0dd367c 100644 --- a/mote_arm/README.md +++ b/mote_arm/README.md @@ -38,7 +38,7 @@ stack). We chose direct Feetech control: `lerobot-calibrate` is two phases — write each servo's homing offset so mid-travel reads 2048, then record every joint's range in one sweep — and both are plain register operations on a bus we already drive. `pixi run - arm-calibrate` implements that flow directly (see `BENCH.md`). + arm-setup calibrate` implements that flow directly (see `BENCH.md`). Everything hardware lives in `mote_description/config/robot.yaml` (`arm:` section): the single source of truth for the port, baud, servo IDs, per-joint @@ -100,19 +100,19 @@ documented: both sides of the language boundary. - **One opener only** — `MoteHardware::on_activate` scans `/proc` for another holder of the port and refuses to start, naming the PID; the read-only bench - tools (`arm-check`, `arm-gains`) do the same through `mote_arm.bus`. So + tools (`arm-setup check`, `arm-setup gains`) do the same through `mote_arm.bus`. So whichever starts first wins and the loser says why, instead of two processes quietly corrupting each other's traffic. The bench tools still open the bus directly, so they still need the control -stack stopped (`pixi run kill`): `arm-check`, `arm-gains`, `arm-calibrate` and -`arm-offsets`. `jog` and `arm-pose` do not — they command the controller. +stack stopped (`pixi run kill`): `arm-setup check`, `arm-setup gains`, `arm-setup calibrate` and +`arm-setup offsets`. `jog` and `arm-pose` do not — they command the controller. ### Where the calibration enters `zero`/`min`/`max` are measurements of one physical arm, so they live in `$MOTE_HOME/arm.yaml` and robot.yaml carries only conservative placeholders -(see "`zero` and `home` are different things" below, and `arm-calibrate`). +(see "`zero` and `home` are different things" below, and `arm-setup calibrate`). The hardware enforces the soft limits, and the hardware reads them from the URDF — so the URDF has to carry the *calibrated* numbers: @@ -177,9 +177,9 @@ conversions are verified without hardware. | `cli.py` | The plumbing every arm CLI shares: strict argument parsing with ROS's own arguments cut out first, and a shutdown that stops spinning before it destroys the node. Both are properties that fail silently otherwise — see "Exits and arguments" below. | | `arm_launch.py` (in `mote_bringup`) | Bench bring-up — the same controller_manager, URDF and `controllers.yaml` as a mission, without the lidar/camera/Nav2. `pixi run arm`. | | `jog` (CLI) | Interactive per-joint jog. A *client of the controller* — publishes clamped trajectories, never opens the bus. `pixi run arm-jog`. | -| `arm_check` (tool) | Standalone enumeration + health + zero snapshot. Read-only, but opens the bus: run with the control stack stopped. `pixi run arm-check`. | -| `calibrate.py` / `arm_calibrate` | Two-phase range calibration: sweep every joint at once, centre its zero, save limits to `$MOTE_HOME/arm.yaml`. Owns the bus: control stack stopped. `pixi run arm-calibrate`. | -| `arm_offsets` (tool) | Read/back up/restore/set the servos' position-correction offsets. The recovery path if a calibration is interrupted. `pixi run arm-offsets`. | +| `arm_check` (tool) | Standalone enumeration + health + zero snapshot. Read-only, but opens the bus: run with the control stack stopped. `pixi run arm-setup check`. | +| `calibrate.py` / `arm_calibrate` | Two-phase range calibration: sweep every joint at once, centre its zero, save limits to `$MOTE_HOME/arm.yaml`. Owns the bus: control stack stopped. `pixi run arm-setup calibrate`. | +| `arm_offsets` (tool) | Read/back up/restore/set the servos' position-correction offsets. The recovery path if a calibration is interrupted. `pixi run arm-setup offsets`. | | `poses.py` / `arm_pose` | Teach and replay named poses, and narrow limits to a working envelope. `pixi run arm-pose save\|list\|go\|limits\|delete`. | | `mock_arm` (node) | The control stack's interface — trajectory topic and `switch_controller` — with nothing behind it, plus an optional synthetic camera, so teleop, recording and replay run on a workstation. `pixi run arm-mock`. | | **teleop + episodes** | Virtual-leader teleoperation and LeRobot-format episode recording — `teleop.py`, `virtual_leader`, `arm_mirror`, `episode_record`, `episode_replay`, `tools/lerobot_export.py`. Its own doc: **[TELEOP.md](TELEOP.md)**. | @@ -225,13 +225,13 @@ still works and says so), and `pixi run arm-pose go home` moves to the rest pose ## Where the soft limits come from -**`pixi run arm-calibrate`**, in two phases — the same shape as LeRobot's +**`pixi run arm-setup calibrate`**, in two phases — the same shape as LeRobot's `lerobot-calibrate`: ``` -pixi run arm-calibrate # sweep, then centre the zeros -pixi run arm-calibrate -- --skip-homing # ranges only; writes nothing -pixi run arm-calibrate -- --joints wrist_roll # redo one joint +pixi run arm-setup calibrate # sweep, then centre the zeros +pixi run arm-setup calibrate --skip-homing # ranges only; writes nothing +pixi run arm-setup calibrate --joints wrist_roll # redo one joint ``` You sweep the joints; everything else is automatic. @@ -279,7 +279,7 @@ Offsets are **modular** — `present = (actual - offset) mod 4096`, so an offset register's ±2047 is therefore folded, never rejected. (Rejecting one aborted a real calibration run before this was understood.) -It opens the serial bus directly, like `arm-check`, so run it with the driver +It opens the serial bus directly, like `arm-setup check`, so run it with the driver stopped: the driver reports radians about the very zero being replaced, and the arm has to stay limp throughout. It asks before releasing torque (an unsupported arm falls) and again before the EEPROM write. @@ -311,7 +311,7 @@ write, the existing offsets are snapshotted to `~/.mote/arm_offsets_backup.yaml` each servo is then written, verified by read-back, *and* checked to have moved its reading by exactly the delta written, before moving to the next. Any failure stops immediately, names the servos already changed, and points at -`pixi run arm-offsets restore`. (An earlier version wrote without a backup and +`pixi run arm-setup offsets restore`. (An earlier version wrote without a backup and died mid-run on a dropped serial read — hence both the snapshot and the guard in `FeetechBus._read`, which turns a short reply into `None` instead of an `IndexError`.) @@ -392,7 +392,7 @@ the `homing_offset` values the servos arrived with (2027, -1723, 1772, -1706, LeRobot writes those registers from the range of motion it records, which is a reasonable thing to do and is not what broke. **What broke is that -`arm-calibrate` then moved the zeros and left the fence where it was**, on +`arm-setup calibrate` then moved the zeros and left the fence where it was**, on 2026-07-28: a band in the corrected frame names different physical angles once the offset under it changes, so the fence silently drifted onto the middle of the joint's travel. Hence this tool writes the fence *and* the offset in one @@ -406,15 +406,15 @@ min/max `record_ranges_of_motion` yields for a joint whose sweep crosses 0/4095 — and `shoulder_pan` is one of the two joints here that do. Two properties made it hard to see. The fence only binds under torque, so -`arm-calibrate` sweeps straight through it by hand and measures the full travel +`arm-setup calibrate` sweeps straight through it by hand and measures the full travel — the calibration and the arm disagree, and only the arm is wrong. And the band is compared against the *corrected* goal, so re-centring a zero moves what it fences without changing a number anyone can read. ``` -pixi run arm-limits show # read-only: the band, in counts and radians -pixi run arm-limits clear # hand every joint its whole 0-4095 range back -pixi run arm-limits restore # write the as-found bands back +pixi run arm-setup limits show # read-only: the band, in counts and radians +pixi run arm-setup limits clear # hand every joint its whole 0-4095 range back +pixi run arm-setup limits restore # write the as-found bands back ``` **The fence and the zero are now written by one run, and never one without the @@ -431,15 +431,15 @@ of correcting it. **The band written is the measured travel, not the soft limits** — wider by `--margin` (0.05 rad) at each end. That is what makes it a backstop rather than a second opinion: `arm.yaml` always binds first, so the fence can never be the -thing that stops the arm in ordinary use, and `arm-limits show` reporting a band +thing that stops the arm in ordinary use, and `arm-setup limits show` reporting a band narrower than the configured one therefore means something is wrong rather than meaning Tuesday. What it catches is a soft limit that has gone wrong — a hand-edited `arm.yaml`, a URDF that never received one, a servo swapped under a stale calibration — where the servo still refuses to drive past its own stops. -There is no `arm-limits set`. A *narrower* envelope belongs in `arm.yaml`, where +There is no `arm-setup limits set`. A *narrower* envelope belongs in `arm.yaml`, where `arm-pose limits` already puts one and where three commands print it; -`arm-limits clear` exists to take the fence off while diagnosing, and `restore` +`arm-setup limits clear` exists to take the fence off while diagnosing, and `restore` to put back whatever was found. ### Named poses, and narrowing the envelope @@ -458,7 +458,7 @@ pixi run arm-pose limits # emit limits spanning the taught poses Poses live in `~/.mote/arm_poses.yaml` (`MOTE_HOME` overrides `~/.mote`) — per-robot data, since a pose only means anything for one physical arm and its calibration. A pose is recorded in radians about its joint's `zero`, so moving a -zero changes which physical position each number names — but `arm-calibrate` +zero changes which physical position each number names — but `arm-setup calibrate` applies exactly the shift it computed, so poses survive a recalibration without being re-taught. Editing a `zero` by hand does not, and invalidates them. @@ -468,7 +468,7 @@ been, and it never learns where the stops are: a joint that barely moved between two poses gets a near-zero band. Its remaining use is the opposite direction — **narrowing** to a working envelope on top of calibrated hard-stop limits, when a task wants a joint held tighter than the mechanism allows. Take the hard stops -from `arm-calibrate` first; reach for `limits` only to pull them in. +from `arm-setup calibrate` first; reach for `limits` only to pull them in. The values committed in `robot.yaml` today still come from the old envelope method, and are flagged as such in that file, pending a calibration pass on the @@ -486,7 +486,7 @@ setpoint it was given: sustained lag beyond `--max-lag` (0.15 rad) for Measured lag on the full swing is a steady 0.07-0.10 rad. `arm-pose go` and `jog` command `arm_controller`, so they run happily alongside -a mission. `arm-check`, `arm-gains`, `arm-calibrate` and `arm-offsets` open the +a mission. `arm-setup check`, `arm-setup gains`, `arm-setup calibrate` and `arm-setup offsets` open the bus directly and so still need the control stack stopped — `MoteHardware`'s own guard will refuse to start against them, and theirs will refuse to start against it. @@ -604,8 +604,8 @@ rad these joints actually travel. See `BENCH.md` for the full runbook. In short: -1. `pixi run arm-check` — confirm every joint responds; note IDs. -2. `pixi run arm-calibrate` — sweep the joints, centre their zeros, save to +1. `pixi run arm-setup check` — confirm every joint responds; note IDs. +2. `pixi run arm-setup calibrate` — sweep the joints, centre their zeros, save to `~/.mote/arm.yaml`. No rebuild: the file is read at load time, not compiled in. This sets `zero` *and* `min`/`max` together, which is the point: limits only mean something relative to the zero they were measured about. @@ -615,7 +615,7 @@ See `BENCH.md` for the full runbook. In short: opposite the expected sign. `invert` changes what the limits mean, so re-calibrate after changing it. -`arm-check -- --save-zero` still prints a bare `zero:` snapshot of the current +`arm-setup check --save-zero` still prints a bare `zero:` snapshot of the current pose. It is a convenience, not calibration: it measures no range and writes no offset, so the limits stay whatever they were. @@ -650,7 +650,7 @@ Run against the real arm on 2026-07-25: | Soft-limit clamp | repeated `+` past the limit held at `+0.103` — no further motion | | Shutdown | SIGINT exits 0, no traceback, torque off, port released | | Pose replay | full `home` <-> `reachy` move (3.19 rad / 183 deg) completed both ways, streamed at 0.5 rad/s, lag steady 0.07-0.10 rad, settling within 0.026-0.041 rad | -| Servo gains | `arm-gains apply` wrote and verified Kp=32 on all six servos; temps unchanged at 27-30 C after the full move | +| Servo gains | `arm-setup gains apply` wrote and verified Kp=32 on all six servos; temps unchanged at 27-30 C after the full move | Gain tuning, on the same arm on 2026-07-28: @@ -673,7 +673,7 @@ completion. The arm shipped with `Kp = 16` on every servo, which left a permanent steady-state error under load: the servo settles where `Kp x error` balances the -holding torque, and `Ki = 0` never integrates that droop away. `arm-gains sweep` +holding torque, and `Ki = 0` never integrates that droop away. `arm-setup gains sweep` (below) measured it on `elbow_flex`, stepped -0.200 rad from rest: | Kp | steady error | reached | load (of 1000) | Kp x error | settle | ripple | @@ -711,12 +711,12 @@ both directions, lag steady at 0.05-0.08 rad, settling within **0.012-0.028 rad* (0.7-1.6 deg) — against 0.026-0.041 rad at `Kp = 32`. Gains live in servo EEPROM, so they are invisible config that a servo swap would -silently revert. `robot.yaml` is the source of truth and `pixi run arm-gains` +silently revert. `robot.yaml` is the source of truth and `pixi run arm-setup gains` reconciles hardware with it: ``` -pixi run arm-gains show # read-only comparison against robot.yaml -pixi run arm-gains apply # write and verify (asks first; EEPROM is persistent) +pixi run arm-setup gains show # read-only comparison against robot.yaml +pixi run arm-setup gains apply # write and verify (asks first; EEPROM is persistent) ``` `apply` reports success only when a confirmed read-back matches, because an @@ -724,14 +724,14 @@ EEPROM read-back races the relock: a single read taken too soon returns a garbled value (observed: 250) and makes a successful write look failed. The bus layer reads twice and trusts the value only when both agree. -### Measuring a gain instead of guessing it: `arm-gains sweep` +### Measuring a gain instead of guessing it: `arm-setup gains sweep` A gain is only defensible against a measurement, so the third subcommand takes one and produces the evidence: ``` -pixi run arm-gains sweep --joint elbow_flex --kp 16,32,64,128 -pixi run arm-gains sweep --joint elbow_flex --kp 32 --ki 0,1,2 # the Ki question +pixi run arm-setup gains sweep --joint elbow_flex --kp 16,32,64,128 +pixi run arm-setup gains sweep --joint elbow_flex --kp 32 --ki 0,1,2 # the Ki question ``` It drives that one joint through the **same** step (`--step`, default -0.2 rad diff --git a/mote_arm/TELEOP.md b/mote_arm/TELEOP.md index 77bd437..9cbf340 100644 --- a/mote_arm/TELEOP.md +++ b/mote_arm/TELEOP.md @@ -125,7 +125,7 @@ below the mirror's `max_velocity` or the follower is permanently behind. commanded and measured rates side by side. A command that keeps moving at 0.25 rad/s while the arm sits at 0.00 rad/s, at any load, is not the mirror and not the deadman: check the servo's own goal-range fence with -`pixi run arm-limits show` (base stopped). It refuses goals outside its band in +`pixi run arm-setup limits show` (base stopped). It refuses goals outside its band in silence, and reads exactly like a joint out of torque. See [README](README.md#the-servos-own-goal-range-limits-which-are-not-the-soft-limits). diff --git a/mote_arm/mote_arm/arm_calibrate.py b/mote_arm/mote_arm/arm_calibrate.py index 71c2c2f..e41a076 100644 --- a/mote_arm/mote_arm/arm_calibrate.py +++ b/mote_arm/mote_arm/arm_calibrate.py @@ -15,9 +15,9 @@ gives a better zero for less effort — and it still works for a joint that crossed the encoder wrap during the sweep, because the recorder unwraps. - pixi run arm-calibrate - pixi run arm-calibrate -- --skip-homing # ranges only, writes nothing - pixi run arm-calibrate -- --joints wrist_roll # redo one joint + pixi run arm-setup calibrate + pixi run arm-setup calibrate --skip-homing # ranges only, writes nothing + pixi run arm-setup calibrate --joints wrist_roll # redo one joint It saves what it measured to ``$MOTE_HOME/arm.yaml`` — per-robot state, not the repo. The zeros and limits describe one physical arm, so they do not belong in @@ -47,7 +47,6 @@ from __future__ import annotations -import argparse import sys import threading import time @@ -55,7 +54,7 @@ from mote_arm import config, poses -from mote_arm.bus import BusError, FeetechBus, port_holders +from mote_arm.bus import BusError from mote_arm.calibrate import ( DEFAULT_MARGIN, CalibrationError, @@ -77,23 +76,6 @@ from mote_arm.config import RAD_PER_COUNT -def _open_bus(cfg) -> FeetechBus: - holders = port_holders(cfg.port) - if holders: - for pid, cmd in holders: - print(f" port held by pid {pid}: {cmd}") - raise SystemExit( - "refusing to share the bus — stop the arm driver / robot base first " - "(`pixi run kill`)." - ) - bus = FeetechBus(cfg.port, cfg.baud_rate) - try: - bus.open() - except BusError as exc: - raise SystemExit(f"cannot open bus: {exc}") - return bus - - def _confirm(prompt: str, assume_yes: bool) -> bool: if assume_yes: return True @@ -211,7 +193,7 @@ def _report_fences(joints, bands, calibrated) -> None: print(f"{name:<16}{f'{low}..{high}':>13}{f'{want_low}..{want_high}':>13}") print( "\n--skip-homing writes nothing to the servos, so nothing was changed. " - "`pixi run arm-limits clear` hands the range back." + "`pixi run arm-setup limits clear` hands the range back." ) @@ -281,10 +263,10 @@ def _phase_centre(bus, joints, recorders, calibrated, args) -> dict[str, int]: when = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%SZ") ids = {j.name: j.id for j in joints} backup = save_offsets_backup(existing, ids, when) - print(f"backed up to {backup} (`pixi run arm-offsets restore` undoes this)") + print(f"backed up to {backup} (`pixi run arm-setup offsets restore` undoes this)") if not limits_backup_path().exists(): fence_backup = save_limits_backup(bands, ids, when) - print(f"backed up to {fence_backup} (`pixi run arm-limits restore`)") + print(f"backed up to {fence_backup} (`pixi run arm-setup limits restore`)") written: list[str] = [] for joint in joints: @@ -324,8 +306,8 @@ def _abort_partial(written: list[str], backup, why: str) -> None: f"\nSTOPPED: {why}\n" f"{len(written)} servo(s) were changed before this: {written or 'none'}.\n" f"The arm is part-way through a calibration. Put it back with:\n" - f" pixi run arm-offsets restore # from {backup}\n" - " pixi run arm-limits restore # the goal-range bands\n" + f" pixi run arm-setup offsets restore # from {backup}\n" + " pixi run arm-setup limits restore # the goal-range bands\n" "then investigate before re-running." ) @@ -483,7 +465,7 @@ def _abort_unsaved(why: str, offsets: dict[str, int]) -> None: f" {config.calibration_path()}\n" "Do not run `pixi run arm` until this is re-run and saves, or the\n" "servos are put back with:\n" - " pixi run arm-offsets restore" + " pixi run arm-setup offsets restore" ) @@ -505,11 +487,12 @@ def _select(cfg, spec_names: str): return [cfg.joint(n) for n in wanted] -def main() -> None: - parser = argparse.ArgumentParser( - description="Guided full-range arm calibration (centre, then sweep)" +def add_subparser(sub) -> None: + parser = sub.add_parser( + "calibrate", + help="guided full-range calibration: sweep, centre the zeros, fence " + "the servos (the once-off, and the only one that needs a human)", ) - parser.add_argument("--robot-yaml", default="", help="override robot.yaml path") parser.add_argument( "--joints", default="", @@ -532,23 +515,16 @@ def main() -> None: parser.add_argument( "--rate", type=float, default=20.0, help="encoder sample rate, Hz" ) - parser.add_argument("--yes", action="store_true", help="skip confirmations") - args = parser.parse_args() + parser.set_defaults(func=run) - cfg = ( - config.ArmConfig.from_yaml_file(args.robot_yaml) - if args.robot_yaml - else config.load() - ) - selected = _select(cfg, args.joints) - bus = _open_bus(cfg) +def run(cfg, bus, args) -> None: + """Calibrate the selected joints against an already-open bus.""" + selected = _select(cfg, args.joints) try: _run(bus, cfg, selected, args) except KeyboardInterrupt: print("\ninterrupted", file=sys.stderr) - finally: - bus.close() def _run(bus, cfg, selected, args) -> None: @@ -556,7 +532,7 @@ def _run(bus, cfg, selected, args) -> None: if missing: raise SystemExit( f"joint(s) {missing} did not respond — fix wiring/IDs before " - "calibrating (see `pixi run arm-check`)." + "calibrating (see `pixi run arm-setup check`)." ) _go_limp(bus, cfg, args) @@ -681,8 +657,4 @@ def _migrate_poses(cfg, calibrated) -> None: def _next_steps() -> None: - print("\n pixi run arm-check # rad reads ~0 at mid-travel") - - -if __name__ == "__main__": - main() + print("\n pixi run arm-setup check # rad reads ~0 at mid-travel") diff --git a/mote_arm/mote_arm/arm_check.py b/mote_arm/mote_arm/arm_check.py index 7ae60a6..8203420 100644 --- a/mote_arm/mote_arm/arm_check.py +++ b/mote_arm/mote_arm/arm_check.py @@ -4,23 +4,19 @@ prints position / voltage / temperature / load. It can also dump a robot.yaml ``zero:`` snippet from the arm's current pose (``--save-zero``). That is a convenience, not calibration: it measures no range, so the limits stay as they -were. `pixi run arm-calibrate` is what sets zeros and limits together. +were. `pixi run arm-setup calibrate` is what sets zeros and limits together. Read-only: it never enables torque or commands a goal, so it is the safe first contact with the arm. Run it with the driver NOT running — the arm shares the drive-wheel bus, so only one process may hold the port: - pixi run arm-check - pixi run arm-check -- --save-zero + pixi run arm-setup check + pixi run arm-setup check --save-zero """ from __future__ import annotations -import argparse import os -from mote_arm import config -from mote_arm.bus import BusError, FeetechBus, port_holders - def _resolve_device(port: str) -> str: """Follow a symlink like /dev/mote_servos to its real /dev/tty* node.""" @@ -61,14 +57,15 @@ def _report_angle_limits(limits: list) -> None: print( f"\n{', '.join(problems)}: the servo refuses goals outside its own " "band, silently, so the joint stops there whatever robot.yaml and " - "arm.yaml say. `pixi run arm-limits clear` hands the whole range " - "back; `pixi run arm-limits show` says what is there now." + "arm.yaml say. `pixi run arm-setup limits clear` hands the whole range " + "back; `pixi run arm-setup limits show` says what is there now." ) -def main() -> None: - parser = argparse.ArgumentParser(description="SO-101 arm bus check") - parser.add_argument("--robot-yaml", default="", help="override robot.yaml path") +def add_subparser(sub) -> None: + parser = sub.add_parser( + "check", help="enumerate the servos and report health (read-only)" + ) parser.add_argument( "--save-zero", "--save-home", @@ -76,57 +73,34 @@ def main() -> None: action="store_true", help="print a robot.yaml zero: snippet from the current pose", ) - args = parser.parse_args() + parser.set_defaults(func=run) - cfg = ( - config.ArmConfig.from_yaml_file(args.robot_yaml) - if args.robot_yaml - else config.load() - ) +def run(cfg, bus, args) -> None: print(f"arm bus: {cfg.port} (-> {_resolve_device(cfg.port)}) @ {cfg.baud_rate}") print(f"expected joints: {cfg.names}") - holders = port_holders(cfg.port) - if holders: - print("\nport is already open by:") - for pid, cmd in holders: - print(f" pid {pid}: {cmd}") - raise SystemExit( - "refusing to share the bus — stop the arm driver / robot base first " - "(`pixi run kill`)." - ) - - try: - bus = FeetechBus(cfg.port, cfg.baud_rate) - bus.open() - except BusError as exc: - raise SystemExit(f"cannot open bus: {exc}") - zeros: list[tuple[str, int]] = [] missing = [] - try: + print( + f"\n{'joint':<14} {'id':>3} {'pos':>5} {'rad':>7} " + f"{'volt':>5} {'temp':>4} {'load':>6}" + ) + limits: list = [] + for joint in cfg.joints: + health = bus.read_health(joint.id) if bus.ping(joint.id) else None + if health is None: + missing.append(joint) + print(f"{joint.name:<14} {joint.id:>3} --- NO RESPONSE ---") + continue + zeros.append((joint.name, health.position)) print( - f"\n{'joint':<14} {'id':>3} {'pos':>5} {'rad':>7} " - f"{'volt':>5} {'temp':>4} {'load':>6}" + f"{joint.name:<14} {joint.id:>3} {health.position:>5} " + f"{joint.counts_to_rad(health.position):>+7.3f} " + f"{health.voltage:>5.1f} {health.temperature:>4} {health.load:>6}" ) - limits: list = [] - for joint in cfg.joints: - health = bus.read_health(joint.id) if bus.ping(joint.id) else None - if health is None: - missing.append(joint) - print(f"{joint.name:<14} {joint.id:>3} --- NO RESPONSE ---") - continue - zeros.append((joint.name, health.position)) - print( - f"{joint.name:<14} {joint.id:>3} {health.position:>5} " - f"{joint.counts_to_rad(health.position):>+7.3f} " - f"{health.voltage:>5.1f} {health.temperature:>4} {health.load:>6}" - ) - limits.append((joint, bus.read_angle_limits(joint.id))) - _report_angle_limits(limits) - finally: - bus.close() + limits.append((joint, bus.read_angle_limits(joint.id))) + _report_angle_limits(limits) if missing: print( @@ -139,12 +113,9 @@ def main() -> None: if args.save_zero and zeros: print( - "\nsnapshot of the current pose (this sets no limits — see arm-calibrate):" + "\nsnapshot of the current pose (this sets no limits — " + "see arm-setup calibrate):" ) print("paste these 'zero:' values into robot.yaml's arm.joints:") for name, counts in zeros: print(f" # {name}: zero: {counts}") - - -if __name__ == "__main__": - main() diff --git a/mote_arm/mote_arm/arm_gains.py b/mote_arm/mote_arm/arm_gains.py index 1f57ec6..f82d2a4 100644 --- a/mote_arm/mote_arm/arm_gains.py +++ b/mote_arm/mote_arm/arm_gains.py @@ -4,9 +4,9 @@ servo and the tuning silently reverts. ``robot.yaml``'s ``arm.gains`` is the source of truth, and this tool reconciles the hardware with it. - pixi run arm-gains show # read-only comparison against robot.yaml - pixi run arm-gains apply # write robot.yaml's gains, verifying each servo - pixi run arm-gains sweep # measure a step response across candidate gains + pixi run arm-setup gains show # read-only comparison against robot.yaml + pixi run arm-setup gains apply # write robot.yaml's gains, verifying each servo + pixi run arm-setup gains sweep # measure a step response across candidate gains Opens the bus directly, so run it with the driver stopped. ``apply`` and ``sweep`` write EEPROM — a persistent change — so they ask first unless @@ -21,14 +21,12 @@ from __future__ import annotations -import argparse import json import time from datetime import datetime, timezone from pathlib import Path -from mote_arm import config -from mote_arm.bus import BusError, FeetechBus, port_holders +from mote_arm.bus import BusError from mote_arm.poses import mote_home from mote_arm.step_response import Sample, StepMetrics, droop_verdict, summarise @@ -38,23 +36,6 @@ MAX_TEMP_C = 55 -def _open_bus(cfg) -> FeetechBus: - holders = port_holders(cfg.port) - if holders: - for pid, cmd in holders: - print(f" port held by pid {pid}: {cmd}") - raise SystemExit( - "refusing to share the bus — stop the arm driver / robot base first " - "(`pixi run kill`)." - ) - bus = FeetechBus(cfg.port, cfg.baud_rate) - try: - bus.open() - except BusError as exc: - raise SystemExit(f"cannot open bus: {exc}") - return bus - - def _report(cfg, bus) -> list[tuple[str, int, tuple[int, int, int] | None]]: want = (cfg.gains.kp, cfg.gains.kd, cfg.gains.ki) print(f"robot.yaml arm.gains: kp={want[0]} kd={want[1]} ki={want[2]}\n") @@ -223,7 +204,7 @@ def _cmd_sweep(cfg, bus, args) -> None: if original is None: raise SystemExit( f"joint {joint.name!r} (id {joint.id}) did not answer a gain read — " - "check the arm is attached and powered (`pixi run arm-check`)" + "check the arm is attached and powered (`pixi run arm-setup check`)" ) # The driver is the only thing that normally fixes a servo's mode, and this @@ -234,7 +215,7 @@ def _cmd_sweep(cfg, bus, args) -> None: raise SystemExit( f"joint {joint.name!r} (id {joint.id}) is not confirmed in position " "mode, so a position goal could spin it continuously instead of " - "stepping. Check it with `pixi run arm-check`; a servo that cannot " + "stepping. Check it with `pixi run arm-setup check`; a servo that cannot " "be read is left untouched rather than blind-written." ) @@ -360,7 +341,7 @@ def _cmd_sweep(cfg, bus, args) -> None: f"\nrestored kp={original[0]} kd={original[1]} ki={original[2]}" if restored else f"\nWARNING: could not restore the original gains {original} — " - "run `arm-gains apply` before using the arm" + "run `arm-setup gains apply` before using the arm" ) if results: @@ -426,15 +407,14 @@ def _default_sweep_path() -> Path: return mote_home() / "arm_gain_sweeps" / f"{stamp}.json" -def main() -> None: - parser = argparse.ArgumentParser(description="Arm servo position-loop gains") - sub = parser.add_subparsers(dest="cmd", required=True) +def add_subparser(sub) -> None: + parser = sub.add_parser("gains", help="the servos' position-loop gains (EEPROM)") + sub = parser.add_subparsers(dest="action", required=True) p_show = sub.add_parser("show", help="read gains and compare to robot.yaml") p_show.set_defaults(func=_cmd_show) p_apply = sub.add_parser("apply", help="write robot.yaml's gains to the servos") - p_apply.add_argument("--yes", action="store_true", help="skip confirmation") p_apply.set_defaults(func=_cmd_apply) p_sweep = sub.add_parser( @@ -487,17 +467,4 @@ def main() -> None: help="stop if the servo reaches this temperature (default: %(default)s C)", ) p_sweep.add_argument("--out", help="where to write the JSON trace") - p_sweep.add_argument("--yes", action="store_true", help="skip confirmation") p_sweep.set_defaults(func=_cmd_sweep) - - args = parser.parse_args() - cfg = config.load() - bus = _open_bus(cfg) - try: - args.func(cfg, bus, args) - finally: - bus.close() - - -if __name__ == "__main__": - main() diff --git a/mote_arm/mote_arm/arm_limits.py b/mote_arm/mote_arm/arm_limits.py index 3b1cdc4..155181c 100644 --- a/mote_arm/mote_arm/arm_limits.py +++ b/mote_arm/mote_arm/arm_limits.py @@ -6,9 +6,9 @@ only, at any load — which reads exactly like running out of torque, and appears in no config file, no URDF and no log. - pixi run arm-limits show # read-only: the band, in counts and radians - pixi run arm-limits clear # hand every joint its whole 0-4095 range back - pixi run arm-limits restore # write the as-found bands back + pixi run arm-setup limits show # read-only: the band, in counts and radians + pixi run arm-setup limits clear # hand every joint its whole 0-4095 range back + pixi run arm-setup limits restore # write the as-found bands back ``clear`` is the normal state for this arm. The soft limits that matter are in ``$MOTE_HOME/arm.yaml``, enforced by ``MoteHardware`` and by ``teleop.py``, @@ -22,11 +22,9 @@ from __future__ import annotations -import argparse from datetime import datetime, timezone -from mote_arm import config -from mote_arm.bus import COUNTS_PER_TURN, BusError, FeetechBus, port_holders +from mote_arm.bus import COUNTS_PER_TURN from mote_arm.calibrate import ( limits_backup_path, load_limits_backup, @@ -36,23 +34,6 @@ FULL_RANGE = (0, COUNTS_PER_TURN - 1) -def _open_bus(cfg) -> FeetechBus: - holders = port_holders(cfg.port) - if holders: - for pid, cmd in holders: - print(f" port held by pid {pid}: {cmd}") - raise SystemExit( - "refusing to share the bus — stop the arm driver / robot base first " - "(`pixi run kill`)." - ) - bus = FeetechBus(cfg.port, cfg.baud_rate) - try: - bus.open() - except BusError as exc: - raise SystemExit(f"cannot open bus: {exc}") - return bus - - def cuts(joint, band: tuple[int, int]) -> bool: """True if the servo's band refuses part of the joint's configured range.""" low, high = sorted((joint.counts_to_rad(band[0]), joint.counts_to_rad(band[1]))) @@ -92,7 +73,7 @@ def _cmd_show(cfg, bus, args) -> None: if fenced: print( f"\n{', '.join(fenced)} stop short of their configured range and say " - "nothing about it. `pixi run arm-limits clear` hands the whole " + "nothing about it. `pixi run arm-setup limits clear` hands the whole " "0-4095 range back; the soft limits in arm.yaml still apply." ) backup = load_limits_backup() @@ -122,7 +103,7 @@ def _write(bus, cfg, wanted: dict[str, tuple[int, int]], args) -> None: if failures: raise SystemExit( f"could not verify the band on: {failures}. " - f"`pixi run arm-limits restore` puts the as-found values back." + f"`pixi run arm-setup limits restore` puts the as-found values back." ) @@ -181,39 +162,20 @@ def _cmd_restore(cfg, bus, args) -> None: _write(bus, cfg, wanted, args) -def main() -> None: - parser = argparse.ArgumentParser( - description="Servo goal-range limits (EEPROM registers 9 and 11)" +def add_subparser(sub) -> None: + parser = sub.add_parser( + "limits", help="the servos' goal-range fence (EEPROM registers 9 and 11)" ) - parser.add_argument("--robot-yaml", default="", help="override robot.yaml path") - parser.add_argument("--yes", action="store_true", help="skip confirmation") - sub = parser.add_subparsers(dest="cmd", required=True) - - sub.add_parser("show", help="read the bands (read-only)").set_defaults( + inner = parser.add_subparsers(dest="action", required=True) + inner.add_parser("show", help="read the bands (read-only)").set_defaults( func=_cmd_show ) - p_clear = sub.add_parser("clear", help="accept the whole 0-4095 range") + p_clear = inner.add_parser("clear", help="accept the whole 0-4095 range") p_clear.add_argument("--joint", default="", help="one joint (default: all)") p_clear.set_defaults(func=_cmd_clear) - sub.add_parser("restore", help="write the as-found bands back").set_defaults( + inner.add_parser("restore", help="write the as-found bands back").set_defaults( func=_cmd_restore ) - args = parser.parse_args() - cfg = ( - config.ArmConfig.from_yaml_file(args.robot_yaml) - if args.robot_yaml - else config.load() - ) - bus = _open_bus(cfg) - try: - args.func(cfg, bus, args) - finally: - bus.close() - - -if __name__ == "__main__": - main() - -__all__ = ["main", "cuts", "FULL_RANGE"] +__all__ = ["add_subparser", "cuts", "FULL_RANGE"] diff --git a/mote_arm/mote_arm/arm_offsets.py b/mote_arm/mote_arm/arm_offsets.py index c29c3e4..98cdd57 100644 --- a/mote_arm/mote_arm/arm_offsets.py +++ b/mote_arm/mote_arm/arm_offsets.py @@ -2,13 +2,13 @@ The offset register (EEPROM, ``SMS_STS_OFS_L/H``) is the one piece of arm state with no copy anywhere else: it lives only in the servo, and overwriting it -destroys the previous value. ``arm-calibrate`` writes it, so this exists to see +destroys the previous value. ``arm-setup calibrate`` writes it, so this exists to see what is there, to put it back, and to set one by hand. - pixi run arm-offsets show # read-only: raw register + decoded value - pixi run arm-offsets backup # snapshot the current offsets to ~/.mote - pixi run arm-offsets restore # write the snapshot back - pixi run arm-offsets set --joint shoulder_pan --value 2027 + pixi run arm-setup offsets show # read-only: raw register + decoded value + pixi run arm-setup offsets backup # snapshot the current offsets to ~/.mote + pixi run arm-setup offsets restore # write the snapshot back + pixi run arm-setup offsets set --joint shoulder_pan --value 2027 Opens the bus directly, so run it with the driver stopped. ``show`` and ``backup`` never write. ``restore`` and ``set`` write EEPROM and ask first. @@ -20,17 +20,9 @@ from __future__ import annotations -import argparse from datetime import datetime, timezone -from mote_arm import config -from mote_arm.bus import ( - OFFSET_MAX, - BusError, - FeetechBus, - decode_sign_magnitude, - port_holders, -) +from mote_arm.bus import OFFSET_MAX, decode_sign_magnitude from mote_arm.calibrate import ( load_offsets_backup, offsets_backup_path, @@ -38,23 +30,6 @@ ) -def _open_bus(cfg) -> FeetechBus: - holders = port_holders(cfg.port) - if holders: - for pid, cmd in holders: - print(f" port held by pid {pid}: {cmd}") - raise SystemExit( - "refusing to share the bus — stop the arm driver / robot base first " - "(`pixi run kill`)." - ) - bus = FeetechBus(cfg.port, cfg.baud_rate) - try: - bus.open() - except BusError as exc: - raise SystemExit(f"cannot open bus: {exc}") - return bus - - def _read_all(cfg, bus) -> dict[str, int | None]: return {j.name: bus.read_homing_offset(j.id) for j in cfg.joints} @@ -147,43 +122,24 @@ def _cmd_set(cfg, bus, args) -> None: _write(bus, cfg, {args.joint: args.value}, args) -def main() -> None: - parser = argparse.ArgumentParser( - description="Servo position-correction offsets (EEPROM)" +def add_subparser(sub) -> None: + parser = sub.add_parser( + "offsets", help="the servos' position-correction registers (EEPROM)" ) - parser.add_argument("--robot-yaml", default="", help="override robot.yaml path") - parser.add_argument("--yes", action="store_true", help="skip confirmation") - sub = parser.add_subparsers(dest="cmd", required=True) - - sub.add_parser("show", help="read the offsets (read-only)").set_defaults( + inner = parser.add_subparsers(dest="action", required=True) + inner.add_parser("show", help="read the offsets (read-only)").set_defaults( func=_cmd_show ) - sub.add_parser("backup", help="snapshot the offsets to ~/.mote").set_defaults( + inner.add_parser("backup", help="snapshot the offsets to ~/.mote").set_defaults( func=_cmd_backup ) - sub.add_parser("restore", help="write the snapshot back").set_defaults( + inner.add_parser("restore", help="write the snapshot back").set_defaults( func=_cmd_restore ) - p_set = sub.add_parser("set", help="write one joint's offset") + p_set = inner.add_parser("set", help="write one joint's offset") p_set.add_argument("--joint", required=True) p_set.add_argument("--value", required=True, type=int) p_set.set_defaults(func=_cmd_set) - args = parser.parse_args() - cfg = ( - config.ArmConfig.from_yaml_file(args.robot_yaml) - if args.robot_yaml - else config.load() - ) - bus = _open_bus(cfg) - try: - args.func(cfg, bus, args) - finally: - bus.close() - - -if __name__ == "__main__": - main() - -__all__ = ["main", "decode_sign_magnitude"] +__all__ = ["add_subparser", "decode_sign_magnitude"] diff --git a/mote_arm/mote_arm/arm_pose.py b/mote_arm/mote_arm/arm_pose.py index dc1a3ae..10f9db0 100644 --- a/mote_arm/mote_arm/arm_pose.py +++ b/mote_arm/mote_arm/arm_pose.py @@ -133,7 +133,7 @@ def _cmd_save(node: PoseClient, args) -> None: held[name] = value # DEFAULT_MARGIN rather than the joint's own: arm.yaml records the # margin per joint but JointSpec does not carry it, and the two - # differ only if someone passed --margin to arm-calibrate. + # differ only if someone passed --margin to arm-setup calibrate. if abs(clamped[name] - value) > DEFAULT_MARGIN: suspect.append(name) @@ -157,7 +157,7 @@ def _cmd_save(node: PoseClient, args) -> None: print( f"\n{', '.join(suspect)}: further out than the calibration margin, " "so the arm and $MOTE_HOME/arm.yaml disagree about this joint's " - "limits. `pixi run arm-calibrate` re-measures them." + "limits. `pixi run arm-setup calibrate` re-measures them." ) diff --git a/mote_arm/mote_arm/arm_setup.py b/mote_arm/mote_arm/arm_setup.py new file mode 100644 index 0000000..7d65d19 --- /dev/null +++ b/mote_arm/mote_arm/arm_setup.py @@ -0,0 +1,80 @@ +"""Everything that configures the arm's servos, behind one command. + + pixi run arm-setup check # read-only: what is on the bus + pixi run arm-setup calibrate # the once-off, needs a human + pixi run arm-setup gains show|apply|sweep + pixi run arm-setup offsets show|backup|restore|set + pixi run arm-setup limits show|clear|restore + +These five were five commands, and being five hid what they have in common: +each opens `/dev/mote_servos` directly, so the control stack has to be stopped +first (`pixi run kill`), and each writes servo EEPROM, which is per-robot +hardware state with no copy in the repo. Four of them carried a byte-identical +copy of the port guard. + +They are also, `check` aside, **once-off**: run when the arm is built, a servo +is swapped, or something is wrong. Nothing here belongs in a session that is +trying to move the arm — for that see `arm-teleop`, `arm-pose` and `arm-record`, +which are clients of `arm_controller` and never touch the bus. + +The register each one owns: + + calibrate the position-correction offsets *and* the goal-range fence, + written together because a fence outlives the frame it was + measured in + gains the position-loop kp/kd/ki + offsets the position-correction offsets, on their own, for recovery + limits the goal-range fence, on its own, for diagnosis +""" + +from __future__ import annotations + +import argparse + +from mote_arm import ( + arm_calibrate, + arm_check, + arm_gains, + arm_limits, + arm_offsets, + cli, + config, +) +from mote_arm.bus import open_bus + +TOOLS = (arm_check, arm_calibrate, arm_gains, arm_offsets, arm_limits) + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + prog="arm-setup", + description="Configure the arm's servos. Run with the base stopped.", + ) + parser.add_argument( + "--robot-yaml", default="", help="override the packaged robot.yaml" + ) + parser.add_argument( + "--yes", action="store_true", help="skip confirmations before EEPROM writes" + ) + sub = parser.add_subparsers(dest="tool", required=True) + for tool in TOOLS: + tool.add_subparser(sub) + return parser + + +def main() -> None: + args = cli.parse(build_parser()) + cfg = ( + config.ArmConfig.from_yaml_file(args.robot_yaml) + if args.robot_yaml + else config.load() + ) + bus = open_bus(cfg) + try: + args.func(cfg, bus, args) + finally: + bus.close() + + +if __name__ == "__main__": + main() diff --git a/mote_arm/mote_arm/bus.py b/mote_arm/mote_arm/bus.py index d6f4470..4cd2924 100644 --- a/mote_arm/mote_arm/bus.py +++ b/mote_arm/mote_arm/bus.py @@ -129,6 +129,31 @@ def port_holders(path: str) -> list[tuple[int, str]]: return holders +def open_bus(cfg) -> "FeetechBus": + """Open the arm's bus for a setup tool, refusing to share it. + + The arm shares the drive-wheel port, so a second opener interleaves packets + with the traffic that moves the robot. Every tool that talks to the servos + directly comes through here: this was copied into four of them, byte for + byte, which is three chances for one of them to grow a different idea of + what "the base is running" means. + """ + holders = port_holders(cfg.port) + if holders: + for pid, cmd in holders: + print(f" port held by pid {pid}: {cmd}") + raise SystemExit( + f"refusing to share {cfg.port} — stop the arm driver / robot base " + "first (`pixi run kill`)." + ) + bus = FeetechBus(cfg.port, cfg.baud_rate) + try: + bus.open() + except BusError as exc: + raise SystemExit(f"cannot open bus: {exc}") + return bus + + class FeetechBus: """Position-mode control of Feetech STS servos over one serial bus.""" diff --git a/mote_arm/mote_arm/calibrate.py b/mote_arm/mote_arm/calibrate.py index 7baaef3..b2676dd 100644 --- a/mote_arm/mote_arm/calibrate.py +++ b/mote_arm/mote_arm/calibrate.py @@ -541,7 +541,7 @@ def calibration_header(recorded: str) -> str: """The comment written above the calibration, for whoever opens the file.""" return ( "# This robot's measured arm calibration — written by " - "`pixi run arm-calibrate`.\n" + "`pixi run arm-setup calibrate`.\n" "#\n" "# Per-robot state, deliberately not in the repo: these are measurements " "of one\n" diff --git a/mote_arm/mote_arm/config.py b/mote_arm/mote_arm/config.py index d324918..b5f60ae 100644 --- a/mote_arm/mote_arm/config.py +++ b/mote_arm/mote_arm/config.py @@ -37,7 +37,7 @@ class JointSpec: id: int min_rad: float max_rad: float - # Raw encoder count that corresponds to 0 rad. Set by `arm-calibrate`; + # Raw encoder count that corresponds to 0 rad. Set by `arm-setup calibrate`; # defaults to the servo mid-point. zero_counts: int = COUNTS_PER_REV // 2 # True if the joint's positive direction is opposite the servo's. @@ -84,7 +84,7 @@ def unreachable(self) -> str | None: f"joint {self.name!r}: soft limits [{self.min_rad:+.3f}, " f"{self.max_rad:+.3f}] but zero={self.zero_counts} leaves only " f"[{lo:+.3f}, {hi:+.3f}] addressable in the 0-{COUNTS_PER_REV - 1} " - "goal register — re-run `pixi run arm-calibrate` to re-centre it" + "goal register — re-run `pixi run arm-setup calibrate` to re-centre it" ) def counts_to_rad(self, counts: int) -> float: @@ -106,7 +106,7 @@ def rad_to_counts(self, rad: float) -> int: class ServoGains: """Position-loop gains held in servo EEPROM (registers 21/22/23). - Recorded in robot.yaml so they survive a servo swap; `arm-gains apply` + Recorded in robot.yaml so they survive a servo swap; `arm-setup gains apply` writes them to the hardware. kp too low leaves a permanent steady-state error under load, since ki=0 never integrates the droop away. """ diff --git a/mote_arm/setup.py b/mote_arm/setup.py index f6e9006..68e7f72 100644 --- a/mote_arm/setup.py +++ b/mote_arm/setup.py @@ -21,12 +21,8 @@ entry_points={ "console_scripts": [ "jog = mote_arm.jog:main", - "arm_check = mote_arm.arm_check:main", - "arm_calibrate = mote_arm.arm_calibrate:main", - "arm_offsets = mote_arm.arm_offsets:main", - "arm_limits = mote_arm.arm_limits:main", + "arm_setup = mote_arm.arm_setup:main", "arm_pose = mote_arm.arm_pose:main", - "arm_gains = mote_arm.arm_gains:main", "virtual_leader = mote_arm.virtual_leader:main", "arm_mirror = mote_arm.mirror:main", "mock_arm = mote_arm.mock_arm:main", diff --git a/mote_arm/test/test_arm_setup.py b/mote_arm/test/test_arm_setup.py new file mode 100644 index 0000000..b87ad2c --- /dev/null +++ b/mote_arm/test/test_arm_setup.py @@ -0,0 +1,78 @@ +"""One command for everything that configures the servos, and one port guard. + +The five tools behind `arm-setup` were five commands, which hid what they have +in common: each opens `/dev/mote_servos` directly, so the control stack must be +stopped first, and each writes servo EEPROM. Four carried a byte-identical copy +of the port guard, which is three chances for one of them to grow a different +idea of what "the base is running" means. + +What is pinned here is the dispatch table — that every subcommand still reaches +the function it used to — and that the shared flags really are shared. +""" + +import pytest + +from mote_arm import arm_setup, cli + +CASES = [ + (["check"], "arm_check", "run"), + (["check", "--save-zero"], "arm_check", "run"), + (["calibrate"], "arm_calibrate", "run"), + (["calibrate", "--skip-homing"], "arm_calibrate", "run"), + (["calibrate", "--joints", "wrist_roll"], "arm_calibrate", "run"), + (["gains", "show"], "arm_gains", "_cmd_show"), + (["gains", "apply"], "arm_gains", "_cmd_apply"), + (["gains", "sweep", "--joint", "elbow_flex"], "arm_gains", "_cmd_sweep"), + (["offsets", "show"], "arm_offsets", "_cmd_show"), + (["offsets", "backup"], "arm_offsets", "_cmd_backup"), + (["offsets", "restore"], "arm_offsets", "_cmd_restore"), + ( + ["offsets", "set", "--joint", "gripper", "--value", "12"], + "arm_offsets", + "_cmd_set", + ), + (["limits", "show"], "arm_limits", "_cmd_show"), + (["limits", "clear"], "arm_limits", "_cmd_clear"), + (["limits", "restore"], "arm_limits", "_cmd_restore"), +] + + +@pytest.mark.parametrize("argv,module,func", CASES) +def test_every_subcommand_reaches_its_own_handler(argv, module, func): + args = arm_setup.build_parser().parse_args(argv) + assert args.func.__module__.rsplit(".", 1)[-1] == module + assert args.func.__name__ == func + + +def test_a_bare_command_is_refused_rather_than_doing_something(): + with pytest.raises(SystemExit): + arm_setup.build_parser().parse_args([]) + + +@pytest.mark.parametrize("tool", ["gains", "offsets", "limits"]) +def test_a_group_with_no_action_is_refused(tool): + with pytest.raises(SystemExit): + arm_setup.build_parser().parse_args([tool]) + + +def test_the_confirmation_skip_is_one_flag_for_every_tool(): + """It was on three of the five, in two different places.""" + for argv in (["calibrate"], ["gains", "apply"], ["offsets", "restore"]): + assert arm_setup.build_parser().parse_args(["--yes", *argv]).yes is True + assert arm_setup.build_parser().parse_args(["check"]).yes is False + + +def test_ros_arguments_are_cut_out_before_parsing(): + """`ros2 run` hands the tool ROS's arguments too; none of the five coped.""" + args = cli.parse( + arm_setup.build_parser(), + ["limits", "clear", "--joint", "gripper", "--ros-args", "-p", "x:=1"], + ) + assert args.joint == "gripper" + + +def test_a_mistyped_flag_is_an_error_rather_than_a_default(): + """Silently dropping it would run the write with a value nobody chose.""" + with pytest.raises(SystemExit) as exc: + cli.parse(arm_setup.build_parser(), ["offsets", "set", "--jiont", "gripper"]) + assert exc.value.code != 0 diff --git a/mote_arm/test/test_calibrate.py b/mote_arm/test/test_calibrate.py index 298fccd..9d336f5 100644 --- a/mote_arm/test/test_calibrate.py +++ b/mote_arm/test/test_calibrate.py @@ -660,7 +660,7 @@ def test_a_failed_save_after_centring_says_the_servos_are_ahead_of_the_file(): said = str(exc.value) assert "refusing to save: bad" in said assert "already been centred" in said - assert "arm-offsets restore" in said + assert "arm-setup offsets restore" in said def test_a_failed_save_under_skip_homing_leaves_nothing_to_recover(): diff --git a/mote_arm/test/test_calibrate_fences.py b/mote_arm/test/test_calibrate_fences.py index c131de8..a8abf88 100644 --- a/mote_arm/test/test_calibrate_fences.py +++ b/mote_arm/test/test_calibrate_fences.py @@ -1,10 +1,10 @@ -"""`arm-calibrate` writing the servos' goal-range fence with the zeros. +"""`arm-setup calibrate` writing the servos' goal-range fence with the zeros. A fence is compared against the *corrected* goal, so it outlives the frame it was measured in: move a zero under one and it goes on refusing the same counts, which now name different angles, in silence. That is how this arm broke — a LeRobot calibration wrote fence and offset together in May 2026, a later -`arm-calibrate` moved the offsets and left the fence behind, and five of six +`arm-setup calibrate` moved the offsets and left the fence behind, and five of six joints spent four months stopping short of their own travel at 0% load. So the properties held here are: the fence is the *measured stops*, wider than @@ -148,7 +148,7 @@ def test_skip_homing_reports_a_cutting_fence_and_writes_nothing(capsys): assert bus.written == [] out = capsys.readouterr().out assert "shoulder_lift" in out and "wrist_roll" not in out - assert "arm-limits clear" in out + assert "arm-setup limits clear" in out def test_skip_homing_says_nothing_when_no_fence_cuts(capsys): diff --git a/mote_arm/tools/bench_teleop.sh b/mote_arm/tools/bench_teleop.sh index fff032e..393a7fc 100755 --- a/mote_arm/tools/bench_teleop.sh +++ b/mote_arm/tools/bench_teleop.sh @@ -61,7 +61,7 @@ rule "0. preconditions" cat <<'EOF' Before starting, confirm at the arm: * it is powered, physically supported, and free to move through its band - * `pixi run arm-gains show` reports kp=32 (droop, not stall — see README) + * `pixi run arm-setup gains show` reports kp=32 (droop, not stall — see README) * the arm is up: `pixi run launch` (with the camera) or `pixi run arm` * `pixi run arm-mirror` is running, unless the arm terminal has mirror:=true * the TELEOP terminal is running `pixi run arm-teleop` — the one you drive diff --git a/mote_bringup/mote_bringup/launch_utils.py b/mote_bringup/mote_bringup/launch_utils.py index 8e9f5f5..e0affac 100644 --- a/mote_bringup/mote_bringup/launch_utils.py +++ b/mote_bringup/mote_bringup/launch_utils.py @@ -94,7 +94,7 @@ def resolved_arm(cfg): ``zero``/``min``/``max`` are measurements of one physical arm, so a calibrated robot keeps them in ``$MOTE_HOME/arm.yaml`` and robot.yaml - carries only conservative placeholders (see `pixi run arm-calibrate`). + carries only conservative placeholders (see `pixi run arm-setup calibrate`). ``mote_arm.config.load`` is the one implementation of that overlay, so it is used here rather than re-read — the alternative is two rules for what this robot's limits are, and the one that reached the hardware would be the wrong diff --git a/pixi.toml b/pixi.toml index 43bcd76..f055661 100644 --- a/pixi.toml +++ b/pixi.toml @@ -19,10 +19,6 @@ test = { cmd = "colcon test --packages-select mote_hardware mote_bringup mote_he # Pi Setup tasks setup-ids = "ros2 run mote_hardware setup_ids" -# SO-101 follower arm bring-up (see mote_arm/README.md, mote_arm/BENCH.md). -# Opens the servo bus directly, so the control stack must be stopped first -# (`pixi run kill`); `arm-jog` and `arm-pose` go through the controller instead. -arm-check = "ros2 run mote_arm arm_check" udev = "sudo cp mote_bringup/udev/99-mote.rules /etc/udev/rules.d/ && sudo udevadm control --reload-rules && sudo udevadm trigger && sudo usermod -aG dialout $USER" # Wifi (mote_bringup/wifi/README.md). `wifi-roaming` writes one modprobe option # and takes effect at the next reboot; it restarts NetworkManager only on a @@ -152,20 +148,11 @@ arm = "ros2 launch mote_bringup arm_launch.py" arm-jog = "ros2 run mote_arm jog" # Teach/replay named arm poses (save is read-only; go asks before moving). arm-pose = "ros2 run mote_arm arm_pose" -# Guided range calibration: sweep each joint to its mechanical stops by hand, -# centre its zero, and save the measured limits to $MOTE_HOME/arm.yaml. Opens -# the bus directly, so run it with the arm driver stopped. This is where soft -# limits come from. -arm-calibrate = "ros2 run mote_arm arm_calibrate" -# Read/back up/restore the servos' position-correction offsets (EEPROM). The -# recovery path if a calibration run is interrupted part-way. -arm-offsets = "ros2 run mote_arm arm_offsets" -# Read/clear/restore the servos' goal-range limits (EEPROM registers 9 and 11). -# A goal outside the band is refused silently, so a fenced joint stops at the -# same angle every time and reads exactly like a joint out of torque. -arm-limits = "ros2 run mote_arm arm_limits" -# Show/apply the arm servos' position-loop gains from robot.yaml (EEPROM). -arm-gains = "ros2 run mote_arm arm_gains" +# Everything that configures the arm's servos, behind one command: check, +# calibrate, gains, offsets, limits. Opens the bus directly, so run it with the +# control stack stopped (`pixi run kill`). Once-off work, `check` aside — the +# arm is *driven* by arm-teleop / arm-pose / arm-record, which never open it. +arm-setup = "ros2 run mote_arm arm_setup" # Virtual-leader teleop (mote_arm/TELEOP.md). No leader arm: the keyboard moves a # leader pose, `arm_mirror` rate-limits and clamps it onto arm_controller. Bring # the control stack up with the mirror beside it: `pixi run arm mirror:=true`. From b408e6feedda5f9b9168f66d6941ee14bb957264 Mon Sep 17 00:00:00 2001 From: Michael Johnson Date: Thu, 3 Sep 2026 13:16:50 +0100 Subject: [PATCH 19/22] Teleop is one process: fold the mirror into it and drop the leader topic `virtual_leader` and `arm_mirror` were two nodes with a `leader/joint_states` topic between them, on the theory that a gamepad or a slider GUI would one day publish that topic instead. Nothing ever did. Nothing off the robot could have: DDS here is loopback-only by design, so the control surface for a remote arm would not be a ROS topic in the first place. And the seam that actually makes a frontend replaceable is `teleop.py` -- every safety rule as a library with no ROS in it, which any frontend imports. What the split bought in practice was a second terminal and a second thing to remember to start. So: one node, `arm_teleop`, holding the keyboard, the safety rules and the arm. `arm-mirror` is gone as an entry point; driving the arm beside a running mission is now just `pixi run arm-teleop`, since it was only ever an arm_controller client. `virtual_leader.py` -> `arm_teleop.py` and `mirror.py` -> a `diagnostics.py` holding the one class worth keeping, both as renames so the history follows. The latched `teleop/estop` topic goes with it. Its stated purpose was that a mirror restarted mid-panic must come up panicked -- and with one process the thing that latches the panic is the thing holding the arm, so exiting drops torque anyway. Two loops survive inside the process, and that part is not incidental: the keyboard reads on the main thread and the safety rules tick on their own, because taking hold of the arm is a `switch_controller` call and a service call made from inside an executor callback can never complete. This also reverts most of 1869458, the `mirror:=` launch argument added earlier today. With no separate mirror node there is nothing for a launch file to start, so `declare_mirror_arg`, `arm_mirror_node` and both call sites go. That argument existed to stop teleop costing a terminal for wanting a camera; merging the processes solves the same problem one level down. 286 tests pass. test_teleop_node.py drives the merged node the way `main` runs it -- executor on a worker thread, tick from this one -- and still holds every safety behaviour: follows, takes hold of a limp arm, rate-limits a jump, halts on release, clamps to the soft limits, latches panic, resumes on clear. Both launch files were generated to confirm the argument and the node are gone. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_017hfWyNtkVh16hyvCK8K9cd --- CLAUDE.md | 33 +-- mote_arm/README.md | 2 +- mote_arm/TELEOP.md | 147 +++++----- .../{virtual_leader.py => arm_teleop.py} | 230 +++++++++++----- mote_arm/mote_arm/diagnostics.py | 85 ++++++ mote_arm/mote_arm/mirror.py | 254 ------------------ mote_arm/setup.py | 3 +- mote_arm/test/teleop_loop/run_teleop_loop.sh | 9 +- mote_arm/test/test_teleop_node.py | 106 ++++---- mote_arm/tools/bench_teleop.sh | 24 +- mote_bringup/launch/arm_launch.py | 10 +- mote_bringup/launch/mote_launch.py | 4 - mote_bringup/mote_bringup/launch_utils.py | 39 +-- mote_bringup/test/test_launch_utils.py | 23 -- pixi.toml | 9 +- 15 files changed, 413 insertions(+), 565 deletions(-) rename mote_arm/mote_arm/{virtual_leader.py => arm_teleop.py} (58%) create mode 100644 mote_arm/mote_arm/diagnostics.py delete mode 100644 mote_arm/mote_arm/mirror.py diff --git a/CLAUDE.md b/CLAUDE.md index 37414fb..a5767a5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -28,8 +28,7 @@ pixi run arm-setup check # Standalone arm bus enumeration + health (read-on pixi run arm-setup calibrate # Range calibration: centre the joints, sweep, emit limits pixi run arm-setup limits # Servo goal-range fence (EEPROM 9/11): show / clear / restore pixi run arm-pose # Teach/replay named arm poses; narrow the envelope -pixi run arm-teleop # Virtual-leader teleop: keyboard -> leader pose (mote_arm/TELEOP.md) -pixi run arm-mirror # Mirror: leader pose -> clamped, rate-limited arm_controller goals +pixi run arm-teleop # Keyboard teleop: clamped, rate-limited arm_controller goals pixi run arm-mock # The arm control stack's interface, no hardware (+ --camera) pixi run arm-record # Record teleop episodes into $MOTE_HOME/episodes pixi run arm-replay # Replay a recorded episode on the arm, gated @@ -931,27 +930,29 @@ section. Contains: control stack stopped (`pixi run kill`). `jog` and `arm-pose` do not. - Torque policy, control interfaces, and calibration in `mote_arm/README.md`; the human bench runbook in `mote_arm/BENCH.md`. -- **Virtual-leader teleop + episode recording** (`mote_arm/TELEOP.md`) — teleop - with **no leader arm**: a leader pose held in software, moved by the keyboard - (`virtual_leader`, `pixi run arm-teleop`), published on `leader/joint_states`, - which `arm_mirror` (`pixi run arm mirror:=true`, or `pixi run launch - mirror:=true` when the camera is wanted too — one switch on both, so teleop - never costs a terminal for wanting a camera; `pixi run arm-mirror` standalone - is for beside a running mission, which takes no such switch) - turns into `arm_controller` trajectories through `control.py`, like every - other command client. **The frontend is deliberately replaceable** — the - mirror's whole contract is `leader/joint_states` + a latched `teleop/estop`, - so a slider GUI or a gamepad is a drop-in. LeRobot's own teleop was rejected +- **Keyboard teleop + episode recording** (`mote_arm/TELEOP.md`) — teleop with + **no leader arm**: a commanded pose held in software and moved by the keyboard + (`arm_teleop`, `pixi run arm-teleop`), turned into `arm_controller` + trajectories through `control.py` like every other command client. **One + process, one node**: it was `virtual_leader` + `arm_mirror` with a + `leader/joint_states` topic between them, on the theory that a gamepad or a + slider GUI would publish that topic instead. Nothing ever did, DDS here is + loopback-only so no remote frontend could, and **the seam that actually makes + a frontend replaceable is `teleop.py` being a ROS-free library** — what the + split bought in practice was a second terminal and a second thing to start. + Two loops survive inside the process and that part is load-bearing (below). + The latched `teleop/estop` topic went with the split: the process that sets + the panic is the one holding the arm, so exiting drops torque anyway. LeRobot's own teleop was rejected for the reason the bring-up rejected LeRobot on the robot at all: it would put torch on the Pi. **Every safety rule lives in `teleop.py`** and nowhere else — soft-limit clamping, a 0.5 rad/s rate limit (so a leader that *jumps* becomes - a ramp), the deadman (the leader's *liveness* is the deadman: a released key, + a ramp), the deadman (the command's *liveness* is the deadman: a released key, a closed window and a dropped SSH session all arrive as "no fresh pose", and - the mirror then issues one goal at the arm's present position so it stops + one goal then goes out at the arm's present position so it stops there rather than coasting on), the latched panic (deactivates `arm_controller` — torque *is* controller activation — and refuses goals until cleared), and re-seeding from measured on every resume so a pause cannot bank - up motion. **The mirror ticks on its own thread, not on a ROS timer**: taking + up motion. **The safety loop ticks on its own thread, not on a ROS timer**: taking hold of the arm is a `switch_controller` call, and a service call made from inside an executor callback can never complete, because the future is resolved by the executor the callback is blocking (`arm-jog` avoids this by driving diff --git a/mote_arm/README.md b/mote_arm/README.md index 0dd367c..5b1b0a7 100644 --- a/mote_arm/README.md +++ b/mote_arm/README.md @@ -182,7 +182,7 @@ conversions are verified without hardware. | `arm_offsets` (tool) | Read/back up/restore/set the servos' position-correction offsets. The recovery path if a calibration is interrupted. `pixi run arm-setup offsets`. | | `poses.py` / `arm_pose` | Teach and replay named poses, and narrow limits to a working envelope. `pixi run arm-pose save\|list\|go\|limits\|delete`. | | `mock_arm` (node) | The control stack's interface — trajectory topic and `switch_controller` — with nothing behind it, plus an optional synthetic camera, so teleop, recording and replay run on a workstation. `pixi run arm-mock`. | -| **teleop + episodes** | Virtual-leader teleoperation and LeRobot-format episode recording — `teleop.py`, `virtual_leader`, `arm_mirror`, `episode_record`, `episode_replay`, `tools/lerobot_export.py`. Its own doc: **[TELEOP.md](TELEOP.md)**. | +| **teleop + episodes** | Keyboard teleoperation and LeRobot-format episode recording — `teleop.py`, `arm_teleop`, `episode_record`, `episode_replay`, `tools/lerobot_export.py`. Its own doc: **[TELEOP.md](TELEOP.md)**. | ## Exits and arguments diff --git a/mote_arm/TELEOP.md b/mote_arm/TELEOP.md index 9cbf340..4be85c3 100644 --- a/mote_arm/TELEOP.md +++ b/mote_arm/TELEOP.md @@ -1,9 +1,9 @@ -# Virtual-leader teleop and episode recording +# Keyboard teleop and episode recording Teleoperating an SO-101 normally takes two arms: an operator moves a **leader** and the **follower** mirrors it. We have one arm and no intention of buying a -second, so the leader here is software — a pose held in a process, moved by the -keyboard, published for the follower to mirror. +second, so the pose the arm follows is held in software and moved by the +keyboard. The point of teleoperating at all is the **episodes**: recorded demonstrations in [LeRobot](https://github.com/huggingface/lerobot)'s dataset format, which is @@ -11,15 +11,15 @@ what a policy would later be learned from. Teleop without recording is just a slower jog CLI. ``` - keyboard ─► virtual_leader ─► arm_mirror ─► arm_controller ─► MoteHardware ─► servos - │ leader/joint_states │ arm_controller/joint_trajectory - │ │ - └─────────► episode_record ◄──── /image_raw/compressed - │ - capture dir - ╱ ╲ - lerobot_export episode_replay - (off-board, LeRobot) (back onto the arm) + keyboard ─► arm_teleop ─► arm_controller ─► MoteHardware ─► servos + │ arm_controller/joint_trajectory + │ + └────► episode_record ◄──── /image_raw/compressed + │ + capture dir + ╱ ╲ + lerobot_export episode_replay + (off-board, LeRobot) (back onto the arm) ``` ## Why this shape @@ -40,11 +40,25 @@ whole design. off-the-shelf SO-101 IK we could drop in, and building one is a separate piece of work. -**A virtual leader publishing joint targets** is what is built. Concretely it is -a keyboard frontend, because the bench is reached over SSH and a GUI is not; but -the frontend is deliberately the replaceable part. `arm_mirror` consumes -`leader/joint_states` and nothing else, so a slider GUI, a gamepad, or a script -is a drop-in — see [Other frontends](#other-frontends). +**A commanded joint pose, moved by the keyboard** is what is built. The frontend +is the replaceable part, and the seam that makes it replaceable is +`mote_arm/teleop.py` — the safety rules as a library with no ROS in it, which a +gamepad or a slider GUI imports. See [Other frontends](#other-frontends). + +### One process, and why it was two + +This was `virtual_leader` and `arm_mirror`, two nodes with a +`leader/joint_states` topic between them, on the theory that a different +frontend would one day publish that topic. Nothing ever did; DDS here is +loopback-only, so no remote frontend could; and the seam that actually makes a +different frontend possible is the library, not the topic. What the split bought +in practice was a second terminal and a second thing to remember to start. + +Two loops survive inside the one process, and that part is not incidental: the +keyboard reads on the main thread and the safety rules tick on their own, +because taking hold of the arm is a `switch_controller` call and a service call +made from inside an executor callback can never complete — the future is +resolved by the executor the callback is blocking. ### Teleop is not jog @@ -60,17 +74,17 @@ Everything that decides whether the arm may move lives in one place — | Rule | What it does | |------|--------------| -| **Soft-limit clamping** | A leader pose outside a joint's `robot.yaml` band is clamped before it becomes a goal. Clamped again in the driver, which is authoritative. | -| **Rate limiting** | The commanded pose advances towards the leader by at most `max_velocity * dt` (0.5 rad/s). A leader that *jumps* — a slider dragged, a frontend restarted at a different pose — produces a ramp, never a lunge. | -| **Deadman** | The leader's liveness *is* the deadman. A frontend publishes only while it is being driven, so a released key, a closed window and a dropped SSH session all arrive as the same thing: no fresh pose. The mirror then issues one goal at the arm's *present* position — stopping it there rather than letting it coast to the setpoint it was travelling towards — and then sends nothing. | -| **Panic latch** | `SPACE` publishes a latched e-stop. Torque *is* controller activation, so the mirror deactivates `arm_controller` — the same switch `arm-jog` uses — and refuses every goal until `z` clears it. Torque coming back cannot restart the move; the latch is transient-local, so a mirror restarted mid-panic comes up panicked. | +| **Soft-limit clamping** | A commanded pose outside a joint's soft band is clamped before it becomes a goal. Clamped again in the driver, which is authoritative. | +| **Rate limiting** | The goal advances towards the commanded pose by at most `max_velocity * dt` (0.5 rad/s). A command that *jumps* — a slider dragged, a frontend restarted at a different pose — produces a ramp, never a lunge. | +| **Deadman** | The command's liveness *is* the deadman. A frontend offers a pose only while it is being driven, so a released key, a closed window and a dropped SSH session all arrive as the same thing: no fresh pose. One goal then goes out at the arm's *present* position — stopping it there rather than letting it coast to the setpoint it was travelling towards — and then nothing. | +| **Panic latch** | `SPACE` latches an e-stop. Torque *is* controller activation, so `arm_controller` is deactivated — the same switch `arm-jog` uses — and every goal is refused until `z` clears it. Torque coming back cannot restart the move. The latch no longer has to outlive the process, because the process that set it also holds the arm: exiting drops torque. | | **Re-seeding** | Resuming after any hold starts from where the arm *is*, not from the command it was last given. Without that, a pause banks up the difference and pays it out as a jump. | -One structural consequence worth knowing: **the mirror ticks on its own thread, +One structural consequence worth knowing: **the safety loop ticks on its own thread, not on a ROS timer.** Taking hold of the arm is a `switch_controller` call, and a service call made from inside an executor callback can never complete — the future is resolved by the executor that the callback is currently blocking. -`arm-jog` avoids this by driving from its REPL thread; the mirror does the same +`arm-jog` avoids this by driving from its REPL thread; teleop does the same with a plain loop while `cli.spin_background` spins the node. Two things the deadman is **not**: it is not a debounce (a single key tap moves @@ -83,22 +97,16 @@ through, and it is bounded at ~0.09 rad by default), and it does not cut torque Three terminals. Everything but the third also runs against `arm-mock`, which is how you should rehearse it — see [Without hardware](#without-hardware). -### 1. Driver and mirror +### 1. The control stack ```bash -pixi run arm mirror:=true # bench: controllers only -pixi run launch mirror:=true # the whole base, when you need the camera +pixi run arm # bench: controllers only +pixi run launch # the whole base, when you need the camera ``` -`mirror:=true` starts `arm_mirror` alongside the control stack — the same switch -on both, so teleop never costs a terminal just because you also wanted the -camera. It is off by default because `arm-jog`, `arm-pose` and replay all -command the same `arm_controller`, and none of them wants a second thing driving -the arm in the same graph. - -During a mission the arm is already up (`pixi run robot` / `mapping` owns the -bus) and neither takes the switch, so teleop there is `pixi run arm-mirror` -beside it. That is the one case where it is a process of its own. +Something has to own the servo bus and offer `arm_controller`. During a mission +`pixi run robot` / `mapping` already does, so teleop runs beside it unchanged — +it is only an `arm_controller` client and never opens the bus. ### 2. Teleop @@ -108,25 +116,25 @@ pixi run arm-teleop ``` hold q/a w/s e/d r/f t/g y/h move joints 1..6 up/down -tap 0 re-sync the leader to where the arm is +tap 0 re-sync the commanded pose to the arm tap SPACE PANIC: torque off, latched z clear it tap [ ] slower / faster ? help x quit ``` -The leader starts synced to the arm, so nothing moves until you press a key, and -it re-syncs whenever it goes idle — it can never bank up a lead the arm has to -chase after you have stopped. +The commanded pose starts synced to the arm, so nothing moves until you press a +key, and it re-syncs whenever it goes idle — it can never bank up a lead the arm +has to chase after you have stopped. -`--speed` (default 0.25 rad/s) sets how fast the leader moves; keep it at or -below the mirror's `max_velocity` or the follower is permanently behind. +`--speed` (default 0.25 rad/s) sets how fast it moves; keep it at or below +`max_velocity` or the arm is permanently behind. **If a joint stops short and stays there**, the live line marks it -`NOT FOLLOWING` and `arm-mirror --ros-args -p diagnose:=true` prints the -commanded and measured rates side by side. A command that keeps moving at -0.25 rad/s while the arm sits at 0.00 rad/s, at any load, is not the mirror and -not the deadman: check the servo's own goal-range fence with -`pixi run arm-setup limits show` (base stopped). It refuses goals outside its band in -silence, and reads exactly like a joint out of torque. See +`NOT FOLLOWING` and `pixi run arm-teleop --ros-args -p diagnose:=true` prints +the commanded and measured rates side by side. A command that keeps moving at +0.25 rad/s while the arm sits at 0.00 rad/s, at any load, is neither the rate +limit nor the deadman: check the servo's own goal-range fence with +`pixi run arm-setup limits show` (base stopped). It refuses goals outside its +band in silence, and reads exactly like a joint out of torque. See [README](README.md#the-servos-own-goal-range-limits-which-are-not-the-soft-limits). ### 3. Record @@ -144,9 +152,9 @@ finishes. Recording samples at 20 Hz: | `observation.images.front` | `/image_raw/compressed`, stored byte-for-byte | | `action` | `arm_controller/joint_trajectory` — what it was commanded to reach | -The action is the *mirror's* output, not the leader's pose, because a policy +The action is the *goal sent to the arm*, not the raw commanded pose, because a policy replaces whatever produces goals — and it is read off the trajectory topic -rather than from the mirror, so a session driven by `arm-jog` records too. +rather than from the teleop node, so a session driven by `arm-jog` records too. > The arm is mounted **rotated 180 degrees** so the camera clears it (GitHub > #2), so episodes do record camera frames. Use `--no-camera` for a robot whose @@ -201,7 +209,7 @@ pixi run -e lerobot -- lerobot-dataset-viz \ pixi run arm-replay -- ~/.mote/episodes/teleop --episode 0 ``` -Stop the virtual leader first — two things commanding `arm_controller` fight +Stop teleop first — two things commanding `arm_controller` fight over the arm. (The stall guard does catch it, which is how that was found, but a caught stall is not a passing replay.) @@ -232,8 +240,7 @@ difference. ```bash pixi run arm-mock -- --camera --droop 0.01 # terminal 1 -pixi run arm-mirror # terminal 2 -pixi run arm-teleop # terminal 3 +pixi run arm-teleop # terminal 2 ``` `--droop` leaves a constant steady-state error, the way a proportional servo @@ -246,7 +253,7 @@ The whole loop runs headless as one command: pixi run arm-teleop-test ``` -It drives the real nodes (mock follower → mirror → `virtual_leader --demo`), +It drives the real nodes (mock follower → `arm_teleop --demo`), records, checks the capture holds an actual motion, replays it, and plans the export. Run it before taking anything here to the bench. @@ -258,45 +265,41 @@ episode — is step 8 of `BENCH.md` and is still open. | Check | Result | |-------|--------| -| Teleop loop, headless | `pixi run arm-teleop-test`: leader -> mirror -> arm_controller -> arm -> record -> replay -> export plan, all green | +| Teleop loop, headless | `pixi run arm-teleop-test`: teleop -> arm_controller -> arm -> record -> replay -> export plan, all green | | Taking hold | the mock starts with `arm_controller` inactive, as the real stack spawns it; the first commanded goal activates it | -| Deadman in the loop | the mirror logged `deadman: no leader input, holding position` / `following the leader` on every pause the demo took | -| Two things commanding one arm | caught by the stall guard before the script learned to stop the leader first — the replay halted at 24/220 with 0.209 rad of lag instead of fighting | +| Deadman in the loop | logged `deadman: no input, holding position` / `following the keyboard` on every pause the demo took | +| Two things commanding one arm | caught by the stall guard before the script learned to stop teleop first — the replay halted at 24/220 with 0.209 rad of lag instead of fighting | | Recording | 220 frames over 10.9 s at 20 fps, 0 dropped ticks, camera frames all distinct | | Replay | 220 setpoints at half speed, lag steady at 0.010 rad (the mock's droop), finished within 0.0000 rad of the last action | | Export (camera) | v3.0 dataset: `data/chunk-000/file-000.parquet`, `videos/observation.images.front/chunk-000/file-000.mp4`, `meta/episodes/chunk-000/file-000.parquet` | | Export (`--no-camera`) | same, state + action only — the path the arm/camera clash forces today | | Loads back through LeRobot | 1 episode, 220 frames, 20 fps, `so101_follower`; sample shapes `observation.state (6,)`, `action (6,)`, `observation.images.front (3, 72, 96)`, task string intact | | LeRobot's own viewer | `lerobot-dataset-viz --save 1` read the dataset and wrote a 619 KB `.rrd` | -| Safety rules | 15 unit tests over `teleop.py` (clamp, rate limit, deadman halt-then-silence, re-seed on resume, panic latch) plus 7 node tests through the mirror against the mock | +| Safety rules | 15 unit tests over `teleop.py` (clamp, rate limit, deadman halt-then-silence, re-seed on resume, panic latch) plus 7 node tests through `ArmTeleop` against the mock | The unit tests are the load-bearing ones: every safety rule is decided in `teleop.py`, so it can be checked exhaustively without a bus. ## Other frontends -`arm_mirror` reads `leader/joint_states` and the latched `teleop/estop`, and -that is the entire contract. Anything that publishes a `JointState` of arm joint -names is a leader. For a slider GUI in the dev environment: - -```bash -pixi run -e dev -- ros2 run joint_state_publisher_gui joint_state_publisher_gui \ - --ros-args -r joint_states:=leader/joint_states -``` +The replaceable part is `mote_arm/teleop.py`: `LeaderMirror` holds every safety +rule — clamping, the rate limit, the deadman, the panic latch — with no ROS in +it. A gamepad, a slider GUI or a script becomes a frontend by importing that and +feeding it poses, exactly as `ArmTeleop` does; what it must not do is command +`arm_controller` around it. -Two caveats, both handled by the mirror rather than by the frontend: the GUI -starts at zero rather than at the arm's pose (the rate limit turns that into a -ramp, but move the sliders to the current pose before it matters), and it -publishes continuously, so its deadman is the window being open rather than a -key being held. +That seam used to be a ROS topic instead, on the theory that a frontend would +publish `leader/joint_states` from somewhere else. Nothing did, and nothing +could have from off the robot: DDS here is loopback-only by design, so the +control surface for a remote arm would not be a topic in the first place. ## Files | Piece | What it is | |-------|------------| | `teleop.py` | The follow rule — clamping, rate limiting, deadman, panic latch. ROS-free, unit-tested. | -| `virtual_leader.py` | Keyboard frontend (`arm-teleop`). `--demo N` sweeps without a terminal. | -| `mirror.py` | `arm_mirror` — the only thing that turns a leader pose into arm motion. | +| `arm_teleop.py` | `arm-teleop`: the keyboard, the safety loop and the arm, in one node. `--demo N` sweeps without a terminal. | +| `diagnostics.py` | `-p diagnose:=true`: tick rate, command rate, arm rate and lag, per second. | | `mock_arm.py` | The control stack's interface with no hardware (`arm-mock`). | | `episode.py` | The capture format: writer, reader, fps resampling. ROS-free. | | `episode_record.py` | `arm-record` — observations and actions into a capture. | diff --git a/mote_arm/mote_arm/virtual_leader.py b/mote_arm/mote_arm/arm_teleop.py similarity index 58% rename from mote_arm/mote_arm/virtual_leader.py rename to mote_arm/mote_arm/arm_teleop.py index 53dc5bd..7f83d97 100644 --- a/mote_arm/mote_arm/virtual_leader.py +++ b/mote_arm/mote_arm/arm_teleop.py @@ -1,33 +1,41 @@ -"""The virtual leader: a leader arm that exists only in software. +"""Keyboard teleoperation of the SO-101 arm. -Leader-follower teleoperation normally needs two arms — an operator moves the -leader and the follower mirrors it. We have one arm. So the leader is a pose -held in this process, moved by the keyboard, published on ``leader/joint_states`` -for ``arm_mirror`` to stream to the follower (and for RViz to draw, if you want -to watch it). - -Nothing here talks to the servo bus, or even to the driver: it publishes a pose -and an e-stop flag, and that is the whole interface. Any other frontend that can -publish ``leader/joint_states`` is a drop-in replacement — a slider GUI, a -gamepad, a script — which is why the leader and the mirror are separate nodes. +One process, one node. The keyboard moves a commanded pose; every safety rule +in `mote_arm.teleop.LeaderMirror` is applied to it — clamping, rate limiting, +the deadman, the panic latch — and the result goes to `arm_controller` through +`mote_arm.control`. Nothing here opens the servo bus. hold q/a w/s e/d r/f t/g y/h move joint 1..6 up/down - tap 0 re-sync the leader to where the arm is + tap 0 re-sync the commanded pose to the arm tap SPACE PANIC: torque off, latched tap z clear the panic latch tap [ ] slower / faster tap ? help x quit -**The deadman is key repeat.** A held key auto-repeats; the leader moves only -while those repeats keep arriving and stops within ``--key-timeout`` of the last -one. Release the key and the leader stops publishing, which is what the mirror -reads as "the operator let go". A single tap therefore produces a short, bounded -move (``key_timeout * speed`` radians) rather than nothing — that is the terminal's -key-repeat behaviour showing through, not a debounce we could tune away without -losing the ability to run this over SSH. - -Whenever it goes idle the leader re-syncs to the follower's measured pose, so it -can never bank up a lead the arm has to chase after the operator has stopped. +**The deadman is key repeat.** A held key auto-repeats; the pose advances only +while those repeats keep arriving and stops within ``--key-timeout`` of the +last one. Release the key and it stops advancing, which is what the mirror +reads as "the operator let go". A single tap therefore produces a short, +bounded move (``key_timeout * speed`` radians) rather than nothing — that is +the terminal's key-repeat behaviour showing through, not a debounce we could +tune away without losing the ability to run this over SSH. + +Whenever it goes idle the commanded pose re-syncs to the arm's measured one, so +it can never bank up a lead the arm has to chase after the operator has +stopped. + +**Two loops, deliberately.** The keyboard reads on the main thread and the +mirror ticks on its own, because taking hold of the arm is a `switch_controller` +call and a service call made from inside an executor callback can never +complete — the future is resolved by the executor the callback is blocking. +`arm-jog` had the same shape for the same reason. + +This was two processes and a `leader/joint_states` topic between them, on the +theory that a gamepad or a slider GUI would one day publish that topic instead. +Nothing ever did, DDS here is loopback-only so no remote frontend could, and the +seam that actually makes a different frontend possible is `teleop.py` being a +library with no ROS in it. What the split bought in practice was a second +terminal and a second thing to remember to start. """ from __future__ import annotations @@ -43,11 +51,11 @@ import rclpy from rclpy.node import Node from sensor_msgs.msg import JointState -from std_msgs.msg import Bool from mote_arm import cli, config, teleop -from mote_arm.teleop import MirrorLimits -from mote_arm.mirror import latched +from mote_arm.control import ArmControl +from mote_arm.diagnostics import Diagnostics +from mote_arm.teleop import ESTOPPED, HOLDING, TRACKING, LeaderMirror, MirrorLimits # Key pairs in joint order: the top row raises a joint, the home row lowers it. KEY_PAIRS = [("q", "a"), ("w", "s"), ("e", "d"), ("r", "f"), ("t", "g"), ("y", "h")] @@ -61,10 +69,18 @@ LIVE_LINE_PERIOD = 0.15 -class VirtualLeader(Node): +class ArmTeleop(Node): + """The keyboard, the safety rules and the arm, in one node.""" + def __init__(self, speed: float, key_timeout: float): - super().__init__("virtual_leader") + super().__init__("arm_teleop") self.declare_parameter("robot_yaml", "") + self.declare_parameter("rate", 20.0) + self.declare_parameter("max_velocity", MirrorLimits.max_velocity) + self.declare_parameter("deadman_timeout", MirrorLimits.deadman_timeout) + # `pixi run arm-teleop --ros-args -p diagnose:=true` + self.declare_parameter("diagnose", False) + path = self.get_parameter("robot_yaml").get_parameter_value().string_value self.cfg = config.ArmConfig.from_yaml_file(path) if path else config.load() @@ -76,16 +92,33 @@ def __init__(self, speed: float, key_timeout: float): # Per joint: which way it is being driven, and when its key last repeated. self._direction: dict[str, float] = {} self._key_time: dict[str, float] = {} - - self._pub = self.create_publisher(JointState, "leader/joint_states", 10) - self._estop_pub = self.create_publisher(Bool, "teleop/estop", latched()) + self._estop_requested = False + + self.mirror = LeaderMirror( + self.cfg.joints, + MirrorLimits( + max_velocity=self.get_parameter("max_velocity").value, + deadman_timeout=self.get_parameter("deadman_timeout").value, + ), + ) + self.arm = ArmControl(self) self.create_subscription(JointState, "joint_states", self._on_states, 10) + self._reported = None + self._stalled: list[str] = [] + self.diagnostics = ( + Diagnostics(self) if self.get_parameter("diagnose").value else None + ) + self.period = 1.0 / max(1.0, self.get_parameter("rate").value) + self.keys: dict[str, tuple[str, float]] = {} for pair, joint in zip(KEY_PAIRS, self.cfg.joints): self.keys[pair[0]] = (joint.name, +1.0) self.keys[pair[1]] = (joint.name, -1.0) + for problem in self.cfg.problems: + self.get_logger().warn(problem) + def _on_states(self, msg: JointState) -> None: with self._lock: for name, position in zip(msg.name, msg.position): @@ -104,7 +137,7 @@ def wait_for_states(self, timeout: float = 5.0) -> bool: return False def sync(self) -> None: - """Put the leader exactly where the arm is.""" + """Put the commanded pose exactly where the arm is.""" self.pose = teleop.sync_pose(self.measured(), self.cfg.joints) def press(self, name: str, direction: float, now: float) -> None: @@ -120,7 +153,7 @@ def driving(self, now: float) -> list[str]: ] def step(self, now: float, dt: float) -> bool: - """Advance the leader pose; True if it is live (an input is being held).""" + """Advance the commanded pose; True if an input is being held.""" live = False for name, last in list(self._key_time.items()): if now - last > self.key_timeout: @@ -133,15 +166,81 @@ def step(self, now: float, dt: float) -> bool: ) return live - def publish(self) -> None: - msg = JointState() - msg.header.stamp = self.get_clock().now().to_msg() - msg.name = [j.name for j in self.cfg.joints if j.name in self.pose] - msg.position = [self.pose[n] for n in msg.name] - self._pub.publish(msg) + def offer(self) -> None: + """Hand the commanded pose to the safety rules. + + Named for what it does rather than for a topic: this used to be a + publish, and the mirror on the other end was free to refuse it. It still + is — `LeaderMirror` clamps, rate-limits and may be latched off. + """ + if self.diagnostics is not None: + self.diagnostics.on_leader(time.monotonic()) + self.mirror.on_leader(dict(self.pose), self._now()) def set_estop(self, engaged: bool) -> None: - self._estop_pub.publish(Bool(data=engaged)) + """Latch or clear the panic. Acted on by the tick, never from here. + + Dropping torque deactivates `arm_controller`, which is a service call, + and a service call cannot complete on the thread the keyboard loop runs + on while the executor is elsewhere. So this records intent and `tick` + performs it. + """ + self._estop_requested = engaged + + def _now(self) -> float: + return self.get_clock().now().nanoseconds * 1e-9 + + def _apply_estop(self) -> None: + if self._estop_requested == self.mirror.estopped: + return + self.mirror.set_estop(self._estop_requested, self._now()) + if self._estop_requested: + self.get_logger().warn("PANIC: dropping torque and refusing goals") + if not self.arm.set_holding(False): + self.get_logger().error( + "could not deactivate arm_controller — the arm may still be " + "holding; stop the control stack or cut power" + ) + else: + self.get_logger().info("panic cleared; following again") + + def tick(self) -> None: + if self.diagnostics is not None: + self.diagnostics.tick(time.monotonic()) + self._apply_estop() + self.mirror.on_measured(self.measured()) + goal = self.mirror.update(self._now(), self.period) + if goal: + # One period to reach the point: the mirror has already rate-limited + # the step to what that allows, and a trajectory the arm cannot + # finish in time just runs ahead of the hardware. + self.arm.send(goal, self.period) + + if self.mirror.stalled != self._stalled: + self._stalled = list(self.mirror.stalled) + if self._stalled: + self.get_logger().warn( + f"not following: {', '.join(self._stalled)} is " + f"{self.mirror.limits.max_lag:.2f} rad behind and not moving — " + "holding the command there rather than driving further ahead" + ) + else: + self.get_logger().info("following again") + + if self.mirror.state != self._reported: + self._reported = self.mirror.state + if self.mirror.state == HOLDING: + self.get_logger().info("deadman: no input, holding position") + elif self.mirror.state == TRACKING: + self.get_logger().info("following the keyboard") + elif self.mirror.state == ESTOPPED: + self.get_logger().warn("e-stopped") + + def run_mirror(self) -> None: + """Tick until the context goes down; runs on its own thread.""" + while rclpy.ok(): + self.tick() + time.sleep(self.period) # True while a live status line is on screen, waiting to be overwritten in place. @@ -175,7 +274,7 @@ def _clear_live() -> None: _live_line = False -def _driving_line(node: VirtualLeader, names: list[str]) -> str: +def _driving_line(node: ArmTeleop, names: list[str]) -> str: """Where the driven joints are, and whether they are against a limit. "Hold the key past the soft limit and watch it stop" is not something an @@ -207,7 +306,7 @@ def _driving_line(node: VirtualLeader, names: list[str]) -> str: return " " + " ".join(parts) -def _help(node: VirtualLeader) -> None: +def _help(node: ArmTeleop) -> None: _out() _out(f"speed {node.speed:.2f} rad/s deadman {node.key_timeout:.2f} s") for pair, joint in zip(KEY_PAIRS, node.cfg.joints): @@ -227,7 +326,7 @@ def _help(node: VirtualLeader) -> None: _out(" p all joint positions ? this help x quit") -def _status(node: VirtualLeader, estopped: bool) -> None: +def _status(node: ArmTeleop, estopped: bool) -> None: measured = node.measured() state = "PANIC" if estopped else "ready" parts = " ".join( @@ -237,13 +336,13 @@ def _status(node: VirtualLeader, estopped: bool) -> None: _out(f"[{state}] {parts}") -def _drive(node: VirtualLeader) -> None: +def _drive(node: ArmTeleop) -> None: period = 1.0 / PUBLISH_RATE_HZ estopped = False idle_since = time.monotonic() last_line = 0.0 - _out("virtual leader — the arm mirrors this pose. '?' for keys, 'x' to quit.") + _out("arm teleop — the arm follows this pose. '?' for keys, 'x' to quit.") if not node.wait_for_states(): _out("warning: no /joint_states — is `pixi run arm` running?") node.sync() @@ -274,7 +373,7 @@ def _drive(node: VirtualLeader) -> None: _out("panic cleared — the arm will follow again.") elif key == SYNC_KEY: node.sync() - _out("leader re-synced to the arm's pose") + _out("re-synced to the arm's pose") elif key == "[": node.speed = max(0.05, node.speed - 0.05) _out(f"speed {node.speed:.2f} rad/s") @@ -289,14 +388,14 @@ def _drive(node: VirtualLeader) -> None: driving = node.driving(now) live = node.step(now, period) and not estopped if live: - node.publish() + node.offer() idle_since = now if now - last_line >= LIVE_LINE_PERIOD: last_line = now _live(_driving_line(node, driving)) elif now - idle_since > node.key_timeout: _clear_live() - # Idle: the leader must not sit ahead of the arm, or resuming would + # Idle: the command must not sit ahead of the arm, or resuming would # pay out the accumulated difference as an unrequested move. node.sync() idle_since = now @@ -304,11 +403,11 @@ def _drive(node: VirtualLeader) -> None: time.sleep(period) -def _demo(node: VirtualLeader, seconds: float) -> None: +def _demo(node: ArmTeleop, seconds: float) -> None: """Drive a canned sweep with no terminal, for tests and unattended checks. It presses the same keys the operator would, through the same code path, so - what it exercises is the real leader — including a deliberate pause in the + what it exercises is the real teleop path — including a deliberate pause in middle, which is the deadman doing its job rather than a gap in the script. """ period = 1.0 / PUBLISH_RATE_HZ @@ -332,26 +431,25 @@ def _demo(node: VirtualLeader, seconds: float) -> None: if not 0.4 <= phase < 0.6: node.press(joint, +1.0 if elapsed < seconds / 2 else -1.0, now) if node.step(now, period): - node.publish() + node.offer() time.sleep(period) _out("demo finished") def main() -> None: - parser = argparse.ArgumentParser( - description="Keyboard virtual leader for the SO-101" - ) + parser = argparse.ArgumentParser(description="Keyboard teleop for the SO-101") parser.add_argument( "--speed", type=float, default=0.25, - help="radians per second the leader moves while a key is held (default 0.25)", + help="radians per second the commanded pose moves while a key is held " + "(default 0.25)", ) parser.add_argument( "--key-timeout", type=float, default=0.35, - help="seconds after the last key repeat before the leader stops (default 0.35)", + help="seconds after the last key repeat before it stops (default 0.35)", ) parser.add_argument( "--demo", @@ -363,9 +461,13 @@ def main() -> None: args = cli.parse(parser) rclpy.init() - node = VirtualLeader(args.speed, args.key_timeout) - + node = ArmTeleop(args.speed, args.key_timeout) spinner = cli.spin_background(node) + # The mirror ticks on a thread of its own: it makes service calls, which + # cannot complete on a thread the executor is blocking, and the keyboard + # owns the main one. + ticker = threading.Thread(target=node.run_mirror, daemon=True) + ticker.start() try: if args.demo is not None: @@ -375,17 +477,21 @@ def main() -> None: except KeyboardInterrupt: pass finally: - print("\nvirtual leader stopped; the arm holds where it is.") + # Leave the arm limp: this process took hold of it, so it gives it back + # rather than leaving a torqued arm behind an exited process. + node.arm.set_holding(False) + print("\nteleop stopped; the arm is limp.") cli.shutdown(node, spinner) + ticker.join(timeout=2.0) -def _interactive(node: VirtualLeader) -> None: +def _interactive(node: ArmTeleop) -> None: """Run the keyboard loop with the terminal in cbreak mode, and restore it.""" if not sys.stdin.isatty(): raise SystemExit( - "the virtual leader needs a terminal (it reads held keys) — run it " - "with `pixi run arm-teleop`, not from a launch file. For an " - "unattended sweep, use --demo SECONDS." + "arm teleop needs a terminal (it reads held keys) — run it with " + "`pixi run arm-teleop`, not from a launch file. For an unattended " + "sweep, use --demo SECONDS." ) settings = termios.tcgetattr(sys.stdin) try: diff --git a/mote_arm/mote_arm/diagnostics.py b/mote_arm/mote_arm/diagnostics.py new file mode 100644 index 0000000..564641a --- /dev/null +++ b/mote_arm/mote_arm/diagnostics.py @@ -0,0 +1,85 @@ +"""Where the arm's motion is being lost, measured rather than reasoned about. + +Split out of the mirror because it answers a question the mirror cannot: when +teleop stutters or falls short of its range, the candidate causes are +indistinguishable from outside the process, and each one is a number. +""" + +from __future__ import annotations + +import time + + +class Diagnostics: + """Where the motion is being lost, measured rather than reasoned about. + + Teleop that stutters or falls short of its range has three candidate causes + and they are indistinguishable from the outside: the mirror not ticking at + the rate it claims, leader poses arriving in gaps, or the arm not achieving + the velocity it is being asked for. Each is a number, so each is printed: + + tick how fast this loop really runs, and its worst period + leader rate the leader pose advances, and the worst gap between two + cmd rate the commanded pose advances -- what the mirror is asking for + arm rate the measured pose advances -- what the arm actually did + lag how far the arm trails the command right now + + cmd at the rate limit with arm well below it, and a lag that grows, is the + arm failing to follow. Both low is the mirror loop. A leader gap over the + deadman is the keyboard loop, which runs at its own rate. Reported for the + joint that moved most over the window, since that is the one being driven. + """ + + def __init__(self, node, period: float = 0.5): + self._node = node + self._period = period + self._reset(time.monotonic()) + self.leader_stamps: list[float] = [] + + def _reset(self, now: float) -> None: + self._start = now + self._ticks = 0 + self._dt_max = 0.0 + self._last_tick = now + self._commanded0 = self._node.mirror.commanded + self._measured0 = self._node.mirror.measured + self.leader_stamps = [] + + def on_leader(self, now: float) -> None: + self.leader_stamps.append(now) + + def tick(self, now: float) -> None: + self._ticks += 1 + self._dt_max = max(self._dt_max, now - self._last_tick) + self._last_tick = now + elapsed = now - self._start + if elapsed < self._period: + return + + commanded = self._node.mirror.commanded + measured = self._node.mirror.measured + moved = {n: abs(v - self._commanded0.get(n, v)) for n, v in commanded.items()} + joint = max(moved, key=moved.get, default=None) + + gaps = [b - a for a, b in zip(self.leader_stamps, self.leader_stamps[1:])] + parts = [ + f"tick {self._ticks / elapsed:4.1f}Hz worst {self._dt_max * 1e3:5.1f}ms", + f"leader {len(self.leader_stamps) / elapsed:4.1f}Hz " + f"worst gap {max(gaps, default=0.0) * 1e3:5.1f}ms", + ] + if joint is not None: + cmd_rate = moved[joint] / elapsed + arm_rate = ( + abs(measured.get(joint, 0.0) - self._measured0.get(joint, 0.0)) + / elapsed + ) + lag = abs(commanded[joint] - measured.get(joint, commanded[joint])) + parts.append( + f"{joint} cmd {cmd_rate:.3f} arm {arm_rate:.3f} rad/s lag {lag:+.3f} rad" + ) + state = self._node.mirror.state + if self._node.mirror.stalled: + state += " STALLED:" + ",".join(self._node.mirror.stalled) + parts.append(state) + self._node.get_logger().info("diag " + " | ".join(parts)) + self._reset(now) diff --git a/mote_arm/mote_arm/mirror.py b/mote_arm/mote_arm/mirror.py deleted file mode 100644 index bd0fc86..0000000 --- a/mote_arm/mote_arm/mirror.py +++ /dev/null @@ -1,254 +0,0 @@ -"""The mirror node: the only thing that turns a virtual leader into arm motion. - -It subscribes to a leader pose, the arm's measured state and the e-stop flag, -and commands `arm_controller` through `mote_arm.control`. Every safety rule -lives in `mote_arm.teleop.LeaderMirror` (clamping, rate limiting, the deadman, -the panic latch) so it can be tested without a bus, a controller, or a terminal; -this node is the ROS wiring around it. - -Keeping it separate from the frontend is what makes the frontend replaceable: -the keyboard leader, a slider GUI publishing `leader/joint_states`, or a -recorded episode being replayed are all the same thing from here. - -**Panic is controller deactivation, and it latches.** Since `MoteHardware` takes -hold of the arm exactly when `arm_controller` claims its command interfaces, -dropping torque means deactivating the controller — the same switch `arm-jog` -uses. The latch then suppresses every goal until it is explicitly cleared, so -the arm cannot resume simply because input started arriving again. - -**The tick loop runs on the main thread, not on a timer.** Taking hold of the -arm is a `switch_controller` service call, and a service call made from inside -an executor callback can never complete: the future is resolved by the executor -that the callback is currently blocking. `arm-jog` gets this right by driving -from its REPL thread; the mirror does the same with a plain loop while -`cli.spin_background` spins the node. -""" - -from __future__ import annotations - -import time - -import rclpy -from rclpy.node import Node -from rclpy.qos import DurabilityPolicy, QoSProfile -from sensor_msgs.msg import JointState -from std_msgs.msg import Bool - -from mote_arm import cli, config -from mote_arm.control import ArmControl -from mote_arm.teleop import ESTOPPED, HOLDING, TRACKING, LeaderMirror, MirrorLimits - - -def latched(depth: int = 1) -> QoSProfile: - """Transient-local QoS for the e-stop flag. - - The latch has to outlive the process that set it: a mirror restarted while - the arm is e-stopped must come up e-stopped, not come up following. - """ - qos = QoSProfile(depth=depth) - qos.durability = DurabilityPolicy.TRANSIENT_LOCAL - return qos - - -class Diagnostics: - """Where the motion is being lost, measured rather than reasoned about. - - Teleop that stutters or falls short of its range has three candidate causes - and they are indistinguishable from the outside: the mirror not ticking at - the rate it claims, leader poses arriving in gaps, or the arm not achieving - the velocity it is being asked for. Each is a number, so each is printed: - - tick how fast this loop really runs, and its worst period - leader arrival rate of leader/joint_states, and the worst gap between two - cmd rate the commanded pose advances -- what the mirror is asking for - arm rate the measured pose advances -- what the arm actually did - lag how far the arm trails the command right now - - cmd at the rate limit with arm well below it, and a lag that grows, is the - arm failing to follow. Both low is the mirror. A leader gap over the deadman - is the input path. Reported for the joint that moved most over the window, - since that is the one being driven. - """ - - def __init__(self, node: "ArmMirror", period: float = 0.5): - self._node = node - self._period = period - self._reset(time.monotonic()) - self.leader_stamps: list[float] = [] - - def _reset(self, now: float) -> None: - self._start = now - self._ticks = 0 - self._dt_max = 0.0 - self._last_tick = now - self._commanded0 = self._node.mirror.commanded - self._measured0 = self._node.mirror.measured - self.leader_stamps = [] - - def on_leader(self, now: float) -> None: - self.leader_stamps.append(now) - - def tick(self, now: float) -> None: - self._ticks += 1 - self._dt_max = max(self._dt_max, now - self._last_tick) - self._last_tick = now - elapsed = now - self._start - if elapsed < self._period: - return - - commanded = self._node.mirror.commanded - measured = self._node.mirror.measured - moved = {n: abs(v - self._commanded0.get(n, v)) for n, v in commanded.items()} - joint = max(moved, key=moved.get, default=None) - - gaps = [b - a for a, b in zip(self.leader_stamps, self.leader_stamps[1:])] - parts = [ - f"tick {self._ticks / elapsed:4.1f}Hz worst {self._dt_max * 1e3:5.1f}ms", - f"leader {len(self.leader_stamps) / elapsed:4.1f}Hz " - f"worst gap {max(gaps, default=0.0) * 1e3:5.1f}ms", - ] - if joint is not None: - cmd_rate = moved[joint] / elapsed - arm_rate = ( - abs(measured.get(joint, 0.0) - self._measured0.get(joint, 0.0)) - / elapsed - ) - lag = abs(commanded[joint] - measured.get(joint, commanded[joint])) - parts.append( - f"{joint} cmd {cmd_rate:.3f} arm {arm_rate:.3f} rad/s lag {lag:+.3f} rad" - ) - state = self._node.mirror.state - if self._node.mirror.stalled: - state += " STALLED:" + ",".join(self._node.mirror.stalled) - parts.append(state) - self._node.get_logger().info("diag " + " | ".join(parts)) - self._reset(now) - - -class ArmMirror(Node): - def __init__(self): - super().__init__("arm_mirror") - self.declare_parameter("robot_yaml", "") - self.declare_parameter("rate", 20.0) - self.declare_parameter("max_velocity", MirrorLimits.max_velocity) - self.declare_parameter("deadman_timeout", MirrorLimits.deadman_timeout) - # `pixi run arm-mirror --ros-args -p diagnose:=true` - self.declare_parameter("diagnose", False) - - path = self.get_parameter("robot_yaml").get_parameter_value().string_value - self.cfg = config.ArmConfig.from_yaml_file(path) if path else config.load() - - self.mirror = LeaderMirror( - self.cfg.joints, - MirrorLimits( - max_velocity=self.get_parameter("max_velocity").value, - deadman_timeout=self.get_parameter("deadman_timeout").value, - ), - ) - - self.arm = ArmControl(self) - self.create_subscription(JointState, "leader/joint_states", self._on_leader, 10) - self.create_subscription(JointState, "joint_states", self._on_states, 10) - self.create_subscription(Bool, "teleop/estop", self._on_estop, latched()) - - self._reported = None - self._stalled = [] - self.diagnostics = ( - Diagnostics(self) if self.get_parameter("diagnose").value else None - ) - self._estop_requested = False - self.period = 1.0 / max(1.0, self.get_parameter("rate").value) - - for problem in self.cfg.problems: - self.get_logger().warn(problem) - - limits = self.mirror.limits - self.get_logger().info( - f"arm_mirror up: max {limits.max_velocity:.2f} rad/s, deadman " - f"{limits.deadman_timeout:.2f} s — leader/joint_states -> arm_controller" - ) - - def _now(self) -> float: - return self.get_clock().now().nanoseconds * 1e-9 - - def _on_leader(self, msg: JointState) -> None: - if self.diagnostics is not None: - self.diagnostics.on_leader(time.monotonic()) - self.mirror.on_leader(dict(zip(msg.name, msg.position)), self._now()) - - def _on_states(self, msg: JointState) -> None: - self.mirror.on_measured(dict(zip(msg.name, msg.position))) - - def _on_estop(self, msg: Bool) -> None: - # Recorded here, acted on in the loop: dropping torque is a service - # call, which cannot complete from inside this callback. - self._estop_requested = msg.data - - def _apply_estop(self) -> None: - if self._estop_requested == self.mirror.estopped: - return - self.mirror.set_estop(self._estop_requested, self._now()) - if self._estop_requested: - self.get_logger().warn("PANIC: dropping torque and refusing goals") - if not self.arm.set_holding(False): - self.get_logger().error( - "could not deactivate arm_controller — the arm may still be " - "holding; stop the control stack or cut power" - ) - else: - self.get_logger().info("panic cleared; following again") - - def tick(self) -> None: - if self.diagnostics is not None: - self.diagnostics.tick(time.monotonic()) - self._apply_estop() - goal = self.mirror.update(self._now(), self.period) - if goal: - # One period to reach the point: the mirror has already rate-limited - # the step to what that allows, and a trajectory the arm cannot - # finish in time just runs ahead of the hardware. - self.arm.send(goal, self.period) - - if self.mirror.stalled != self._stalled: - self._stalled = list(self.mirror.stalled) - if self._stalled: - self.get_logger().warn( - f"not following: {', '.join(self._stalled)} is " - f"{self.mirror.limits.max_lag:.2f} rad behind and not moving — " - "holding the command there rather than driving further ahead" - ) - else: - self.get_logger().info("following again") - - if self.mirror.state != self._reported: - self._reported = self.mirror.state - if self.mirror.state == HOLDING: - self.get_logger().info("deadman: no leader input, holding position") - elif self.mirror.state == TRACKING: - self.get_logger().info("following the leader") - elif self.mirror.state == ESTOPPED: - self.get_logger().warn("e-stopped") - - def run(self) -> None: - while rclpy.ok(): - self.tick() - time.sleep(self.period) - - -def main() -> None: - rclpy.init() - node = ArmMirror() - spinner = cli.spin_background(node) - try: - node.run() - except KeyboardInterrupt: - pass - finally: - # Leave the arm limp: the mirror took hold of it, so the mirror gives it - # back rather than leaving a torqued arm behind an exited process. - node.arm.set_holding(False) - cli.shutdown(node, spinner) - - -if __name__ == "__main__": - main() diff --git a/mote_arm/setup.py b/mote_arm/setup.py index 68e7f72..454ed4e 100644 --- a/mote_arm/setup.py +++ b/mote_arm/setup.py @@ -23,8 +23,7 @@ "jog = mote_arm.jog:main", "arm_setup = mote_arm.arm_setup:main", "arm_pose = mote_arm.arm_pose:main", - "virtual_leader = mote_arm.virtual_leader:main", - "arm_mirror = mote_arm.mirror:main", + "arm_teleop = mote_arm.arm_teleop:main", "mock_arm = mote_arm.mock_arm:main", "episode_record = mote_arm.episode_record:main", "episode_replay = mote_arm.episode_replay:main", diff --git a/mote_arm/test/teleop_loop/run_teleop_loop.sh b/mote_arm/test/teleop_loop/run_teleop_loop.sh index 2e9912c..0683428 100755 --- a/mote_arm/test/teleop_loop/run_teleop_loop.sh +++ b/mote_arm/test/teleop_loop/run_teleop_loop.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash # The whole teleop loop against a follower that isn't there. # -# mock_arm (+ synthetic camera) -> arm_mirror -> virtual_leader --demo +# mock_arm (+ synthetic camera) -> arm_teleop --demo # -> episode_record -> episode_replay # # Nothing here needs the arm, the camera, or a terminal, so it is the gate to @@ -86,11 +86,10 @@ echo "== 1/6 mock follower with a synthetic camera ==" # so the mock does too. Without it the mock lands exactly on every setpoint and # the recorded action would be indistinguishable from the observed state. background mock_arm ros2 run mote_arm mock_arm --camera --rate 20 --speed 1.0 --droop 0.01 -background mirror ros2 run mote_arm arm_mirror sleep 4 -echo "== 2/6 teleop: virtual leader -> mirror -> follower, ${DEMO_SECONDS}s ==" -background leader ros2 run mote_arm virtual_leader -- --demo "$DEMO_SECONDS" --speed 0.3 +echo "== 2/6 teleop -> follower, ${DEMO_SECONDS}s ==" +background teleop ros2 run mote_arm arm_teleop -- --demo "$DEMO_SECONDS" --speed 0.3 echo "== 3/6 record the session ==" ros2 run mote_arm episode_record -- \ @@ -113,7 +112,7 @@ echo "== 5/6 replay it on the follower at half speed ==" # arm_controller itself, and two things commanding one arm fight. (The stall # guard does catch it — that is how this was found — but a caught stall is not # a passing replay.) -stop leader mirror +stop teleop sleep 1 ros2 run mote_arm episode_replay -- "$CAPTURE" --episode 0 --yes --speed-scale 0.5 \ >"$LOGS/replay.log" 2>&1 || fail "replay exited non-zero" diff --git a/mote_arm/test/test_teleop_node.py b/mote_arm/test/test_teleop_node.py index 0db4b85..b0e90eb 100644 --- a/mote_arm/test/test_teleop_node.py +++ b/mote_arm/test/test_teleop_node.py @@ -1,9 +1,9 @@ -"""Virtual-leader teleop end to end, against a control stack that isn't there. +"""Keyboard teleop end to end, against a control stack that isn't there. ``mock_arm`` presents the interface ros2_control does — a trajectory topic and -``switch_controller`` — with no bus behind it, so the whole path (leader pose -> -mirror -> arm_controller -> arm) runs in one process and every safety behaviour -can be checked before anyone stands at the bench. +``switch_controller`` — with no bus behind it, so the whole path (commanded pose +-> safety rules -> arm_controller -> arm) runs in one process and every safety +behaviour can be checked before anyone stands at the bench. The mirror is driven the way it is in production: the executor spins on a worker thread and ``tick()`` is called from this one. That is not a test @@ -11,6 +11,9 @@ service call made from inside an executor callback can never complete, because the future is resolved by the executor the callback is blocking. +The keyboard is the one thing stubbed: these set ``pose`` where a held key +would, which is exactly what ``ArmTeleop.step`` does. + A random ROS_DOMAIN_ID keeps these nodes off a live robot's graph (they command ``arm_controller``, which moves a real arm), and a per-process namespace keeps them off sibling test sessions colcon runs in parallel. @@ -27,12 +30,8 @@ import pytest # noqa: E402 import rclpy # noqa: E402 from rclpy.executors import SingleThreadedExecutor # noqa: E402 -from rclpy.node import Node # noqa: E402 -from sensor_msgs.msg import JointState # noqa: E402 -from std_msgs.msg import Bool # noqa: E402 - -from mote_arm import config, mirror as mirror_mod, mock_arm as mock_mod # noqa: E402 -from mote_arm.mirror import latched # noqa: E402 +from mote_arm import arm_teleop as teleop_mod # noqa: E402 +from mote_arm import config, mock_arm as mock_mod # noqa: E402 CFG = config.ArmConfig.from_dict( { @@ -52,42 +51,30 @@ ) -class Leader(Node): - """Stands in for the keyboard frontend: publishes a leader pose on demand.""" - - def __init__(self): - super().__init__("leader_stub") - self._pub = self.create_publisher(JointState, "leader/joint_states", 10) - self._estop = self.create_publisher(Bool, "teleop/estop", latched()) - self.pose: dict[str, float] | None = None - self.create_timer(0.05, self._tick) - - def _tick(self) -> None: - if self.pose is None: - return - msg = JointState() - msg.header.stamp = self.get_clock().now().to_msg() - msg.name = list(self.pose) - msg.position = [self.pose[n] for n in msg.name] - self._pub.publish(msg) - - def panic(self, engaged: bool) -> None: - self._estop.publish(Bool(data=engaged)) - - class Stack: - def __init__(self, mock, mirror, leader, executor): + """The teleop node, its follower, and the two loops that drive them.""" + + def __init__(self, mock, teleop, executor): self.mock = mock - self.mirror = mirror - self.leader = leader + self.teleop = teleop self._executor = executor + self.pose: dict[str, float] | None = None def run(self, seconds: float) -> None: - """Tick the mirror for a while, as its own main loop does.""" + """Advance both loops, as `main` runs them on two threads.""" deadline = time.monotonic() + seconds while time.monotonic() < deadline: - self.mirror.tick() - time.sleep(self.mirror.period) + if self.pose is not None: + # Where a held key would put it. Offered every pass, because a + # pose offered once and then not again is the operator letting + # go, which is what the deadman is for. + self.teleop.pose = dict(self.pose) + self.teleop.offer() + self.teleop.tick() + time.sleep(self.teleop.period) + + def panic(self, engaged: bool) -> None: + self.teleop.set_estop(engaged) def at(self, joint: str) -> float: return self.mock.position[joint] @@ -98,31 +85,30 @@ def stack(monkeypatch): monkeypatch.setattr(config, "load", lambda: CFG) rclpy.init(args=["--ros-args", "-r", f"__ns:=/test_{os.getpid()}"]) mock = mock_mod.MockArm(MOCK_ARGS) - mirror = mirror_mod.ArmMirror() - leader = Leader() + teleop = teleop_mod.ArmTeleop(speed=0.25, key_timeout=0.35) executor = SingleThreadedExecutor() - for node in (mock, mirror, leader): + for node in (mock, teleop): executor.add_node(node) spinner = threading.Thread(target=executor.spin, daemon=True) spinner.start() - built = Stack(mock, mirror, leader, executor) - # Let the mock's first joint_states reach the mirror, which refuses to - # command an arm it has not heard from. + built = Stack(mock, teleop, executor) + # Let the mock's first joint_states arrive: the mirror refuses to command an + # arm it has not heard from. time.sleep(0.3) yield built executor.shutdown() spinner.join(timeout=2.0) - for node in (mock, mirror, leader): + for node in (mock, teleop): node.destroy_node() rclpy.shutdown() -def test_the_arm_follows_the_virtual_leader(stack): +def test_the_arm_follows_the_keyboard(stack): start = stack.at("elbow_flex") - stack.leader.pose = {"elbow_flex": 0.6} + stack.pose = {"elbow_flex": 0.6} stack.run(0.6) assert stack.at("elbow_flex") > start + 0.1 @@ -131,23 +117,23 @@ def test_commanding_takes_hold_of_a_limp_arm(stack): # The mock starts with arm_controller inactive, exactly as the real stack # spawns it; the first command is what makes the hardware take hold. assert stack.mock.holding is False - stack.leader.pose = {"elbow_flex": 0.4} + stack.pose = {"elbow_flex": 0.4} stack.run(0.3) assert stack.mock.holding is True def test_following_is_rate_limited_not_instant(stack): - # A leader that jumps must not become an arm that jumps: the default 0.5 + # A command that jumps must not become an arm that jumps: the default 0.5 # rad/s over ~0.5 s is a few tenths of a radian, nowhere near the target. - stack.leader.pose = {"elbow_flex": 1.0} + stack.pose = {"elbow_flex": 1.0} stack.run(0.5) assert stack.at("elbow_flex") < 0.45 def test_releasing_the_input_halts_the_arm(stack): - stack.leader.pose = {"elbow_flex": 1.0} + stack.pose = {"elbow_flex": 1.0} stack.run(0.6) - stack.leader.pose = None # the operator let go + stack.pose = None # the operator let go stack.run(0.6) halted = stack.at("elbow_flex") @@ -156,20 +142,20 @@ def test_releasing_the_input_halts_the_arm(stack): def test_goals_are_clamped_to_the_soft_limits(stack): - stack.leader.pose = {"wrist_roll": 5.0} + stack.pose = {"wrist_roll": 5.0} stack.run(1.2) assert stack.at("wrist_roll") == pytest.approx(0.1, abs=1e-3) def test_panic_drops_torque_and_the_arm_stops_even_while_driven(stack): - stack.leader.pose = {"elbow_flex": 1.0} + stack.pose = {"elbow_flex": 1.0} stack.run(0.4) - stack.leader.panic(True) + stack.panic(True) stack.run(0.4) # Torque is controller activation, so dropping it means deactivating. assert stack.mock.holding is False - # The leader keeps publishing throughout: the latch, not the absence of + # The pose keeps being offered throughout: the latch, not the absence of # input, is what holds the arm. stopped = stack.at("elbow_flex") stack.run(0.6) @@ -177,12 +163,12 @@ def test_panic_drops_torque_and_the_arm_stops_even_while_driven(stack): def test_clearing_panic_lets_the_arm_move_again(stack): - stack.leader.pose = {"elbow_flex": 1.0} + stack.pose = {"elbow_flex": 1.0} stack.run(0.3) - stack.leader.panic(True) + stack.panic(True) stack.run(0.3) stopped = stack.at("elbow_flex") - stack.leader.panic(False) + stack.panic(False) stack.run(0.6) assert stack.at("elbow_flex") > stopped + 0.05 diff --git a/mote_arm/tools/bench_teleop.sh b/mote_arm/tools/bench_teleop.sh index 393a7fc..367c786 100755 --- a/mote_arm/tools/bench_teleop.sh +++ b/mote_arm/tools/bench_teleop.sh @@ -9,9 +9,9 @@ # Three terminals. The first is the robot, the second is what you drive, and # the third is this script: # -# 1. pixi run launch mirror:=true base + camera + the teleop mirror -# (`pixi run arm mirror:=true` is the same thing without lidar/camera) -# 2. pixi run arm-teleop the virtual leader — YOU DRIVE THIS ONE +# 1. pixi run launch base + camera +# (`pixi run arm` is the same thing without lidar/camera) +# 2. pixi run arm-teleop YOU DRIVE THIS ONE # 3. pixi run arm-bench-teleop <- this script: asks, records, replays # # It writes a report you can paste into the task; nothing is recorded as passing @@ -63,7 +63,6 @@ Before starting, confirm at the arm: * it is powered, physically supported, and free to move through its band * `pixi run arm-setup gains show` reports kp=32 (droop, not stall — see README) * the arm is up: `pixi run launch` (with the camera) or `pixi run arm` - * `pixi run arm-mirror` is running, unless the arm terminal has mirror:=true * the TELEOP terminal is running `pixi run arm-teleop` — the one you drive This is the last terminal: it asks the questions and records the answers. @@ -79,17 +78,10 @@ else exit 1 fi NODES="$(ros2 node list 2>/dev/null)" -if grep -q arm_mirror <<<"$NODES"; then - note " PASS arm_mirror is up" +if grep -q arm_teleop <<<"$NODES"; then + note " PASS arm_teleop is up" else - note " FAIL arm_mirror is not running — run \`pixi run arm-mirror\`, or" - note " start the arm terminal with mirror:=true" - exit 1 -fi -if grep -q virtual_leader <<<"$NODES"; then - note " PASS the virtual leader is up" -else - note " FAIL no virtual_leader — every check below asks you to drive the arm" + note " FAIL no arm_teleop — every check below asks you to drive the arm" note " from it. Open another terminal and run \`pixi run arm-teleop\`." exit 1 fi @@ -166,12 +158,12 @@ echo # about the arm at all. echo -n " waiting for the virtual leader to exit" for _ in $(seq 60); do - ros2 node list 2>/dev/null | grep -q virtual_leader || break + ros2 node list 2>/dev/null | grep -q arm_teleop || break echo -n "." sleep 2 done echo -if ros2 node list 2>/dev/null | grep -q virtual_leader; then +if ros2 node list 2>/dev/null | grep -q arm_teleop; then note " SKIP replay: the virtual leader is still running after 2 minutes" FAILURES=$((FAILURES + 1)) else diff --git a/mote_bringup/launch/arm_launch.py b/mote_bringup/launch/arm_launch.py index 4387747..ab008b0 100644 --- a/mote_bringup/launch/arm_launch.py +++ b/mote_bringup/launch/arm_launch.py @@ -14,9 +14,9 @@ `pixi run arm-jog` (or `switch_controllers --activate arm_controller`) asks it to hold. -`mirror:=true` additionally runs `arm_mirror`, so a virtual-leader teleop -session is two terminals (this one and `pixi run arm-teleop`) rather than -three. See `mote_arm/TELEOP.md`. +Teleop is `pixi run arm-teleop` in a second terminal beside this one; it is one +process holding the keyboard, the safety rules and the arm, so this launch +starts nothing on its behalf. See `mote_arm/TELEOP.md`. """ import os @@ -32,9 +32,7 @@ from mote_bringup.launch_utils import ( INACTIVE_CONTROLLERS, arm_config_file, - arm_mirror_node, controller_spawn_handler, - declare_mirror_arg, joint_params_file, resolved_arm, ) @@ -84,7 +82,6 @@ def generate_launch_description(): return LaunchDescription( [ - declare_mirror_arg(), SetParameter(name="use_sim_time", value=False), robot_state_publisher, controller_manager, @@ -93,6 +90,5 @@ def generate_launch_description(): active=("joint_state_broadcaster",), inactive=INACTIVE_CONTROLLERS, ), - arm_mirror_node(), ] ) diff --git a/mote_bringup/launch/mote_launch.py b/mote_bringup/launch/mote_launch.py index 695d68f..8786315 100644 --- a/mote_bringup/launch/mote_launch.py +++ b/mote_bringup/launch/mote_launch.py @@ -15,9 +15,7 @@ ICP_ODOM_FRAME, INACTIVE_CONTROLLERS, arm_config_file, - arm_mirror_node, controller_spawn_handler, - declare_mirror_arg, joint_params_file, resolved_arm, ) @@ -205,7 +203,6 @@ def generate_launch_description(): description="Run foxglove_bridge alongside the base. Set false " "when mote-foxglove.service already runs it.", ), - declare_mirror_arg(), SetParameter(name="use_sim_time", value=use_sim_time), robot_state_publisher, controller_manager, @@ -222,6 +219,5 @@ def generate_launch_description(): localization, twist_mux, foxglove, - arm_mirror_node(), ] ) diff --git a/mote_bringup/mote_bringup/launch_utils.py b/mote_bringup/mote_bringup/launch_utils.py index e0affac..158cf5e 100644 --- a/mote_bringup/mote_bringup/launch_utils.py +++ b/mote_bringup/mote_bringup/launch_utils.py @@ -7,10 +7,8 @@ import tempfile import yaml -from launch.actions import DeclareLaunchArgument, OpaqueFunction, RegisterEventHandler -from launch.conditions import IfCondition +from launch.actions import OpaqueFunction, RegisterEventHandler from launch.event_handlers import OnProcessStart -from launch.substitutions import LaunchConfiguration from launch_ros.actions import Node from mote_arm import config as arm_config @@ -41,41 +39,6 @@ INACTIVE_CONTROLLERS = ("arm_controller",) -MIRROR_ARG = "mirror" - - -def declare_mirror_arg(): - """The `mirror:=` switch, declared identically wherever the arm comes up. - - Off by default everywhere: `arm-jog`, `arm-pose` and episode replay all - command `arm_controller` too, and none of them wants a second thing driving - the arm in the same graph. - """ - return DeclareLaunchArgument( - MIRROR_ARG, - default_value="false", - description="also run arm_mirror, for virtual-leader teleop " - "(see mote_arm/TELEOP.md)", - ) - - -def arm_mirror_node(): - """`arm_mirror`, conditioned on `mirror:=`. - - Shared so that every way of bringing the arm up offers teleop the same way. - It was on `arm_launch.py` alone, which meant a session needing the camera - had to run `pixi run launch` and then the mirror in a terminal of its own — - a third window that existed only because two launch files disagreed. - """ - return Node( - package="mote_arm", - executable="arm_mirror", - name="arm_mirror", - output="screen", - condition=IfCondition(LaunchConfiguration(MIRROR_ARG)), - ) - - def arm_on_wheel_bus(cfg): """True when the arm is part of the wheel bus's ros2_control component. diff --git a/mote_bringup/test/test_launch_utils.py b/mote_bringup/test/test_launch_utils.py index ca8184b..04dbfba 100644 --- a/mote_bringup/test/test_launch_utils.py +++ b/mote_bringup/test/test_launch_utils.py @@ -13,10 +13,7 @@ from mote_bringup.launch_utils import ( CONTROLLERS, - MIRROR_ARG, - arm_mirror_node, controller_spawn_handler, - declare_mirror_arg, spawn_controllers, ) @@ -70,23 +67,3 @@ def test_opaque_function_yields_fresh_spawners_on_repeated_execution(): assert all(isinstance(p, Node) for p in first + second) for a, b in zip(first, second): assert a is not b - - -def test_the_mirror_switch_is_declared_the_same_way_wherever_it_appears(): - """One definition, because two launch files disagreeing costs a terminal. - - `mirror:=` was on arm_launch.py alone, so a bench session needing the camera - ran `pixi run launch` and then arm_mirror in a window of its own — a third - terminal that existed only because the two files differed. - """ - arg = declare_mirror_arg() - assert arg.name == MIRROR_ARG == "mirror" - # Off by default: arm-jog, arm-pose and episode replay all command - # arm_controller too, and none wants a second thing driving the arm. - assert arg.default_value[0].text == "false" - - -def test_the_mirror_node_is_the_arm_package_s_own(): - node = arm_mirror_node() - assert node.node_package == "mote_arm" - assert node.node_executable == "arm_mirror" diff --git a/pixi.toml b/pixi.toml index f055661..df8f748 100644 --- a/pixi.toml +++ b/pixi.toml @@ -153,11 +153,10 @@ arm-pose = "ros2 run mote_arm arm_pose" # control stack stopped (`pixi run kill`). Once-off work, `check` aside — the # arm is *driven* by arm-teleop / arm-pose / arm-record, which never open it. arm-setup = "ros2 run mote_arm arm_setup" -# Virtual-leader teleop (mote_arm/TELEOP.md). No leader arm: the keyboard moves a -# leader pose, `arm_mirror` rate-limits and clamps it onto arm_controller. Bring -# the control stack up with the mirror beside it: `pixi run arm mirror:=true`. -arm-teleop = "ros2 run mote_arm virtual_leader" -arm-mirror = "ros2 run mote_arm arm_mirror" +# Keyboard teleop (mote_arm/TELEOP.md): one process holding the keyboard, the +# safety rules and the arm. Needs a stack that owns the servo bus beside it — +# `pixi run arm`, `pixi run launch`, or a mission. +arm-teleop = "ros2 run mote_arm arm_teleop" # The arm control stack's interface (trajectory topic + switch_controller) with # no hardware behind it — teleop, recording and replay all run against this on a # workstation. `-- --camera` adds a synthetic camera so episodes have frames. From 062408b7c9e67445d139ca4bedc8a9e577aa05de Mon Sep 17 00:00:00 2001 From: Michael Johnson Date: Thu, 3 Sep 2026 13:21:42 +0100 Subject: [PATCH 20/22] Retire `jog`: teleop gains a step mode, on the path with the safety rules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `arm-jog` was a second keyboard path to the arm and it had none of `teleop.py`'s rules — no rate limit, no deadman, no panic latch, only the soft-limit clamp. It existed for one thing teleop could not do: move a joint by a measured increment rather than for as long as a key is held. So teleop does that now. `m` toggles hold mode and step mode; in step mode each press advances one joint by `--step` (0.05 rad). A held key auto-repeats and a terminal cannot tell a repeat from a fresh press, so a repeat inside `key_timeout` is ignored: holding steps once, and stepping again means releasing and pressing again. That is what jog's typed step and Enter gave, on the one path that is rate-limited, deadman-guarded and panic-latched. A step is offered until the arm has had time to walk it (`settle_time`, the increment over `max_velocity`) rather than once, because the deadman would otherwise fire mid-travel and stop the arm short of the increment asked for. Two things go with jog and are worth naming rather than discovering: a per-joint "drive to 0 rad" command, and `torque on|off`, which SPACE and z replace. The BENCH.md step that jogged each joint through a small range now uses step mode. One latent bug fell out: `h` was listed as a help key, but `h` also drives joint 6 down and the joint keys are matched first, so help there could never fire. `?` alone now. 286 tests pass, six of them new: a press steps exactly once, a repeat does not, releasing and re-pressing does, a step clamps like any other command, the settle window is the step over the rate limit, and stepping the mock arm moves it by about the step. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_017hfWyNtkVh16hyvCK8K9cd --- CLAUDE.md | 22 +-- mote_arm/BENCH.md | 28 +-- mote_arm/README.md | 19 +- mote_arm/TELEOP.md | 17 +- mote_arm/mote_arm/arm_pose.py | 2 +- mote_arm/mote_arm/arm_teleop.py | 79 ++++++++- mote_arm/mote_arm/episode_record.py | 2 +- mote_arm/mote_arm/jog.py | 204 ---------------------- mote_arm/mote_arm/mock_arm.py | 2 +- mote_arm/setup.py | 1 - mote_arm/test/test_control.py | 18 -- mote_arm/test/test_control_holding.py | 2 +- mote_arm/test/test_jog.py | 22 --- mote_arm/test/test_teleop_node.py | 50 ++++++ mote_bringup/launch/arm_launch.py | 2 +- mote_bringup/mote_bringup/launch_utils.py | 2 +- pixi.toml | 5 +- 17 files changed, 174 insertions(+), 303 deletions(-) delete mode 100644 mote_arm/mote_arm/jog.py delete mode 100644 mote_arm/test/test_jog.py diff --git a/CLAUDE.md b/CLAUDE.md index a5767a5..d06cd53 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -23,7 +23,6 @@ pixi run teleop # Keyboard teleoperation pixi run explore # Autonomous mapping coverage (run beside `pixi run mapping`, on the Pi) pixi run tasks # Task layer: behaviour-tree task_server (see mote_tasks) pixi run arm # SO-101 arm: bench control stack (ros2_control, no mission) -pixi run arm-jog # Interactive per-joint jog CLI (needs a stack owning the bus) pixi run arm-setup check # Standalone arm bus enumeration + health (read-only, base stopped) pixi run arm-setup calibrate # Range calibration: centre the joints, sweep, emit limits pixi run arm-setup limits # Servo goal-range fence (EEPROM 9/11): show / clear / restore @@ -729,16 +728,19 @@ section. Contains: `xacro mote.urdf.xacro` falls back to the placeholders — fine for checking generation, wrong for driving a calibrated arm, because calibration moves the zero and every commanded angle then names a different position. -- `jog` (CLI, `pixi run arm-jog`) — interactive per-joint jog; a *client of - `arm_controller`* (publishes clamped single-point trajectories, limps on - exit). It never opens the bus, so there is no contention to guard against. +- **`jog` is retired.** It was a second keyboard path to the arm with none of + `teleop.py`'s rules — no rate limit, no deadman, no panic latch — for a + capability `arm-teleop`'s step mode (`m`, `--step`) now covers on the path + that has them. What went with it: a per-joint "drive to 0 rad" command, and a + `torque on|off` REPL command that `SPACE`/`z` replace. - **Every arm CLI exits and parses through `cli.py`**, because both properties fail silently when hand-rolled. `cli.shutdown(node, spinner)` shuts the context down, **joins the spin thread, and only then destroys the node**: destroying a node `spin()` still holds aborts the interpreter (exit 134, "terminate called without an active exception") *after* the tool has done its - work, so the run succeeds and the process still crashes — measured on `jog` - and `arm-pose list`, 3 of 3 runs each, with no hardware attached. This is not + work, so the run succeeds and the process still crashes — measured on the + jog CLI (since retired) and `arm-pose list`, 3 of 3 runs each, with no + hardware attached. This is not a rare race, so a new arm CLI must not hand-roll the teardown. `cli.parse(parser)` cuts the `--ros-args ... --` block out and then parses strictly: `ros2 run` hands the tool ROS's arguments too, so a plain @@ -753,7 +755,7 @@ section. Contains: `home` is a taught *pose* in `~/.mote/arm_poses.yaml`, normally the arm's rest position. Both were spelled "home" until 2026-07-28 and it confused an operator at the bench, so the config key is `zero:` (`home:` still parses), - `jog`'s command is `zero` (`home` aliases it with a note), and `arm-setup check` has + the jog CLI's command was `zero` rather than `home`, and `arm-setup check` has `--save-zero`. Do not reintroduce the collision. - `calibrate.py` + `arm_calibrate` (`pixi run arm-setup calibrate`) — **where the soft limits come from**, in LeRobot's two phases. A bus owner, not a driver client @@ -927,7 +929,7 @@ section. Contains: `MoteHardware`, and both `MoteHardware::on_activate` and `mote_arm.bus` refuse a port another process already holds (naming the PID) — so the read-only bench tools (`arm-setup check`, `arm-setup gains`), which still open the bus directly, need the - control stack stopped (`pixi run kill`). `jog` and `arm-pose` do not. + control stack stopped (`pixi run kill`). `arm-teleop` and `arm-pose` do not. - Torque policy, control interfaces, and calibration in `mote_arm/README.md`; the human bench runbook in `mote_arm/BENCH.md`. - **Keyboard teleop + episode recording** (`mote_arm/TELEOP.md`) — teleop with @@ -955,7 +957,7 @@ section. Contains: up motion. **The safety loop ticks on its own thread, not on a ROS timer**: taking hold of the arm is a `switch_controller` call, and a service call made from inside an executor callback can never complete, because the future is resolved - by the executor the callback is blocking (`arm-jog` avoids this by driving + by the executor the callback is blocking (the jog CLI avoided this by driving from its REPL thread). `mock_arm` (`pixi run arm-mock`) presents that same ros2_control surface — trajectory topic plus `switch_controller` — with no bus and an optional pure-zlib synthetic camera, so the whole loop runs on a @@ -963,7 +965,7 @@ section. Contains: pre-bench gate; `pixi run arm-bench-teleop` is the guided hardware session. **Episodes**: `episode_record` samples `joint_states` (observation), the `arm_controller/joint_trajectory` topic (action — read off the wire rather - than from the mirror, so an `arm-jog` session records too) and + than from the teleop node, so an `arm-pose` session records too) and `/image_raw/compressed` into a **capture** under `$MOTE_HOME/episodes/` — JSON lines plus the compressed frames stored byte-for-byte, written with the standard library alone, because the Pi carries no parquet or ffmpeg. diff --git a/mote_arm/BENCH.md b/mote_arm/BENCH.md index 1641c2a..00c2907 100644 --- a/mote_arm/BENCH.md +++ b/mote_arm/BENCH.md @@ -24,7 +24,7 @@ recalibration. What is still open: - **Does the homing offset apply to commanded goals, or only to feedback?** The read side is proven — positions moved by exactly the predicted delta on every joint, which is what "written and confirmed" checks. The write side - shows up on the first `arm-jog` move after calibrating: if a commanded angle + shows up on the first `arm-teleop` move after calibrating: if a commanded angle lands roughly one offset away from where you asked, `config.rad_to_counts` has to compensate. Try this first. - **Does `wrist_roll` have real stops?** It swept 5.89 rad, 94% of a turn, and @@ -47,7 +47,7 @@ now comes up with the base rather than instead of it. Only one thing on this bench still needs the base stopped: the tools that open the bus directly — `arm-setup check` and `arm-setup gains`. Run `pixi run kill` before those; they refuse to start otherwise, naming the process that holds the port. -`arm-jog` and `arm-pose` command the controller and need no such care. +`arm-teleop` and `arm-pose` command the controller and need no such care. ## Step 2 — enumerate + health check @@ -261,15 +261,17 @@ arm is part of the mission stack now, and this step works during a mission). Terminal C: ``` -pixi run arm-jog +pixi run arm-teleop ``` +Press `m` for step mode: one 0.05 rad increment per key press, which is what +this step wants and what `arm-jog` used to give. `?` prints the key map. + For **each** joint in turn (arm supported, ready to cut power): -1. Select it by number (e.g. `0` for `shoulder_pan`). The status line shows its - measured position and soft limits. -2. `step 0.05` to set a small increment. -3. Jog `+` a few times, then `-` back — watch the joint move a small amount in +1. Find its key pair in the help (`q`/`a` is joint 1, down to `y`/`h`). +2. Press the raise key a few times, then the lower key back — watch the joint + move a small amount in the commanded direction, and `/joint_states` (Terminal B) track it. - If the joint moves the **wrong way**, set `invert: true` for it in `robot.yaml`, rebuild, and repeat. @@ -350,8 +352,8 @@ criterion 2.** ## Step 7 — torque-off on exit, and a clean exit -In `arm-jog`, type `quit`. **Expected:** `limping arm (deactivating -arm_controller) and exiting...`; the arm goes back-drivable immediately. Stop +In `arm-teleop`, press `x`. **Expected:** `teleop stopped; the arm is limp.` +and the arm goes back-drivable immediately. Stop `arm` (Ctrl-C) and confirm it also logs a clean shutdown and leaves the arm limp. **Nothing should move on startup or shutdown.** @@ -360,7 +362,7 @@ the time the process falls over, so an abort here is invisible unless looked for: ``` -pixi run arm-jog # 'quit' at the prompt +pixi run arm-teleop # 'x' at the prompt echo $? # expect 0 pixi run arm-pose list echo $? # expect 0 @@ -437,15 +439,15 @@ Still open: - [ ] `shoulder_pan` can be commanded to 0 rad (its packaged band still excludes its own zero; the calibrated band on the arm does not) - [ ] the offset applies to commanded goals, not only to feedback — the first - `arm-jog` move settles it + `arm-teleop` move settles it - [ ] **the arm moving under ros2_control on the real robot** — everything about the fold is verified against a simulated bus (`mote_hardware/test/test_arm_bus.cpp`), not against servos: confirm - `arm-jog` still moves `elbow_flex` in the commanded direction, that the + `arm-teleop` still moves `elbow_flex` in the commanded direction, that the soft limits still hold, and that activating `arm_controller` takes hold without a snap - [ ] **the arm moving while the wheels are driving** — the point of the fold. - `pixi run robot`, drive a short goal, and jog the arm at the same time; + `pixi run robot`, drive a short goal, and teleop the arm at the same time; watch for wheel-odometry glitches that would mean the bus is oversubscribed - [ ] step 8: teleop, record, export/inspect and replay on the arm (`pixi run arm-bench-teleop`) — verified headless against the mock diff --git a/mote_arm/README.md b/mote_arm/README.md index 5b1b0a7..7ec939a 100644 --- a/mote_arm/README.md +++ b/mote_arm/README.md @@ -106,7 +106,7 @@ documented: The bench tools still open the bus directly, so they still need the control stack stopped (`pixi run kill`): `arm-setup check`, `arm-setup gains`, `arm-setup calibrate` and -`arm-setup offsets`. `jog` and `arm-pose` do not — they command the controller. +`arm-setup offsets`. `arm-teleop` and `arm-pose` do not — they command the controller. ### Where the calibration enters @@ -176,7 +176,6 @@ conversions are verified without hardware. | `control.py` | The one place that knows how to talk to `arm_controller`: single-point trajectories, and activation as the torque switch. | | `cli.py` | The plumbing every arm CLI shares: strict argument parsing with ROS's own arguments cut out first, and a shutdown that stops spinning before it destroys the node. Both are properties that fail silently otherwise — see "Exits and arguments" below. | | `arm_launch.py` (in `mote_bringup`) | Bench bring-up — the same controller_manager, URDF and `controllers.yaml` as a mission, without the lidar/camera/Nav2. `pixi run arm`. | -| `jog` (CLI) | Interactive per-joint jog. A *client of the controller* — publishes clamped trajectories, never opens the bus. `pixi run arm-jog`. | | `arm_check` (tool) | Standalone enumeration + health + zero snapshot. Read-only, but opens the bus: run with the control stack stopped. `pixi run arm-setup check`. | | `calibrate.py` / `arm_calibrate` | Two-phase range calibration: sweep every joint at once, centre its zero, save limits to `$MOTE_HOME/arm.yaml`. Owns the bus: control stack stopped. `pixi run arm-setup calibrate`. | | `arm_offsets` (tool) | Read/back up/restore/set the servos' position-correction offsets. The recovery path if a calibration is interrupted. `pixi run arm-setup offsets`. | @@ -195,7 +194,7 @@ executor is pulled out from under itself and the interpreter calls after the tool has already done its work. The fix is ordering — shut the context down, *join the spin thread*, and only then destroy — which is what `cli.shutdown(node, spinner)` is for. Measured on this arm's CLIs with no -hardware attached: `jog` (stdin closed) and `arm-pose list` each aborted 3 of 3 +hardware attached: the jog CLI (stdin closed) and `arm-pose list` each aborted 3 of 3 runs before, and exited 0 on 3 of 3 after. It is not a rare race — with no stack running to talk to, it reproduced every time. `test_cli.py` watches a child process's exit status, because nothing in-process can catch an abort. @@ -220,7 +219,7 @@ and it confused an operator at the bench: | **zero** | The encoder count that reads 0 rad. After calibration, the *middle of the joint's travel*. | `robot.yaml`, `arm.joints[].zero` | | **home** | A taught *pose*, normally the arm's rest position. Nothing to do with 0 rad. | `~/.mote/arm_poses.yaml` | -So `arm-jog`'s command to drive a joint to 0 rad is `zero`, not `home` (`home` +So the jog CLI's command to drive a joint to 0 rad was `zero`, not `home` (`home` still works and says so), and `pixi run arm-pose go home` moves to the rest pose. ## Where the soft limits come from @@ -485,7 +484,7 @@ setpoint it was given: sustained lag beyond `--max-lag` (0.15 rad) for `--stall-time` means it is no longer keeping up, and the move stops where it is. Measured lag on the full swing is a steady 0.07-0.10 rad. -`arm-pose go` and `jog` command `arm_controller`, so they run happily alongside +`arm-pose go` and `arm-teleop` command `arm_controller`, so they run happily alongside a mission. `arm-setup check`, `arm-setup gains`, `arm-setup calibrate` and `arm-setup offsets` open the bus directly and so still need the control stack stopped — `MoteHardware`'s own guard will refuse to start against them, and theirs will refuse to start against @@ -503,7 +502,7 @@ treats a STRICT refusal as success when the controller turns out to already be in the state requested. Assuming `inactive` at construction made the *second* `arm-pose go` of a session fail on every streamed setpoint: `Controller with name 'arm_controller' is already active` / `Aborting, no controller is -switched!`, at 20 Hz. It also made `arm-jog`'s documented limp-on-exit silently +switched!`, at 20 Hz. It also made the jog CLI's documented limp-on-exit silently do nothing when something else had left the arm holding. **A taught pose is stored reachable.** `save` clamps each joint into its soft @@ -555,14 +554,14 @@ became a consequence of who holds the command interfaces. per control cycle (~120 ms for all six) so no single realtime cycle pays for six read-plus-write pairs, and a joint whose position cannot be read stays limp rather than being driven against an unknown goal. -- **Letting go:** deactivating `arm_controller` (`jog`'s `torque off`, or - quitting `jog`) drops torque immediately, inside the switch itself rather than +- **Letting go:** deactivating `arm_controller` (`arm-teleop`'s `SPACE`, or + quitting it) drops torque immediately, inside the switch itself rather than on the next write — a component being torn down may never write again. - **Shutdown:** deactivating the hardware stops the wheels and limps the arm. Goals are soft-clamped to the per-joint limits from `robot.yaml` **in the hardware**, on the far side of every client, so a trajectory controller, the jog -CLI and the task layer are all held to the same envelope. `jog` clamps again +CLI and the task layer are all held to the same envelope. `arm-teleop` clamps again client-side purely for immediate feedback. ## Control interfaces @@ -611,7 +610,7 @@ See `BENCH.md` for the full runbook. In short: only mean something relative to the zero they were measured about. 3. Re-teach only the poses it reported as outside the new limits — the rest are migrated for you. -4. Jog each joint (`pixi run arm-jog`) and flip `invert` for any that moves +4. Step each joint (`pixi run arm-teleop`, `m` for step mode) and flip `invert` for any that moves opposite the expected sign. `invert` changes what the limits mean, so re-calibrate after changing it. diff --git a/mote_arm/TELEOP.md b/mote_arm/TELEOP.md index 4be85c3..7b28df3 100644 --- a/mote_arm/TELEOP.md +++ b/mote_arm/TELEOP.md @@ -62,10 +62,11 @@ resolved by the executor the callback is blocking. ### Teleop is not jog -`arm-jog` types a discrete step and presses Enter. Teleop holds a key and the -arm moves continuously until you let go. That difference is the reason this -exists: an episode recorded from stop-start hops teaches a policy stop-start -hops. +The retired `arm-jog` typed a discrete step and pressed Enter. Teleop holds a +key and the arm moves continuously until you let go. That difference is the +reason this exists: an episode recorded from stop-start hops teaches a policy +stop-start hops. Step mode (`m`) is still there when a measured increment is +what you want, which is what jog was for. ## Safety @@ -77,14 +78,14 @@ Everything that decides whether the arm may move lives in one place — | **Soft-limit clamping** | A commanded pose outside a joint's soft band is clamped before it becomes a goal. Clamped again in the driver, which is authoritative. | | **Rate limiting** | The goal advances towards the commanded pose by at most `max_velocity * dt` (0.5 rad/s). A command that *jumps* — a slider dragged, a frontend restarted at a different pose — produces a ramp, never a lunge. | | **Deadman** | The command's liveness *is* the deadman. A frontend offers a pose only while it is being driven, so a released key, a closed window and a dropped SSH session all arrive as the same thing: no fresh pose. One goal then goes out at the arm's *present* position — stopping it there rather than letting it coast to the setpoint it was travelling towards — and then nothing. | -| **Panic latch** | `SPACE` latches an e-stop. Torque *is* controller activation, so `arm_controller` is deactivated — the same switch `arm-jog` uses — and every goal is refused until `z` clears it. Torque coming back cannot restart the move. The latch no longer has to outlive the process, because the process that set it also holds the arm: exiting drops torque. | +| **Panic latch** | `SPACE` latches an e-stop. Torque *is* controller activation, so `arm_controller` is deactivated — the same switch `arm-pose` uses — and every goal is refused until `z` clears it. Torque coming back cannot restart the move. The latch no longer has to outlive the process, because the process that set it also holds the arm: exiting drops torque. | | **Re-seeding** | Resuming after any hold starts from where the arm *is*, not from the command it was last given. Without that, a pause banks up the difference and pays it out as a jump. | One structural consequence worth knowing: **the safety loop ticks on its own thread, not on a ROS timer.** Taking hold of the arm is a `switch_controller` call, and a service call made from inside an executor callback can never complete — the future is resolved by the executor that the callback is currently blocking. -`arm-jog` avoids this by driving from its REPL thread; teleop does the same +The retired jog CLI avoided this by driving from its REPL thread; teleop does the same with a plain loop while `cli.spin_background` spins the node. Two things the deadman is **not**: it is not a debounce (a single key tap moves @@ -154,7 +155,7 @@ finishes. Recording samples at 20 Hz: The action is the *goal sent to the arm*, not the raw commanded pose, because a policy replaces whatever produces goals — and it is read off the trajectory topic -rather than from the teleop node, so a session driven by `arm-jog` records too. +rather than from the teleop node, so a session driven by `arm-pose` records too. > The arm is mounted **rotated 180 degrees** so the camera clears it (GitHub > #2), so episodes do record camera frames. Use `--no-camera` for a robot whose @@ -305,7 +306,7 @@ control surface for a remote arm would not be a topic in the first place. | `episode_record.py` | `arm-record` — observations and actions into a capture. | | `episode_replay.py` | `arm-replay` — a capture back onto the arm, gated. | | `motion.py` | Lag supervision, shared with `arm-pose go`. | -| `control.py` | Shared with `arm-jog`: single-point trajectories, and activation as the torque switch. | +| `control.py` | Shared with `arm-pose` and replay: single-point trajectories, and activation as the torque switch. | | `tools/lerobot_export.py` | Capture → LeRobotDataset, off-board (`-e lerobot`). | | `test/teleop_loop/` | The headless end-to-end gate (`arm-teleop-test`). | | `tools/bench_teleop.sh` | The guided hardware session (see `BENCH.md`). | diff --git a/mote_arm/mote_arm/arm_pose.py b/mote_arm/mote_arm/arm_pose.py index 10f9db0..39a7349 100644 --- a/mote_arm/mote_arm/arm_pose.py +++ b/mote_arm/mote_arm/arm_pose.py @@ -15,7 +15,7 @@ margin inside those, so a raw capture is routinely a fraction outside the band and could never be replayed. ``go`` is the only command that moves the arm, and it leaves the arm *holding* the pose it reached (deactivate ``arm_controller``, -or run ``arm-jog`` and ``torque off``, to make it limp again): it reports the +or press SPACE in ``arm-teleop``, to make it limp again): it reports the distance each joint will travel and then moves. There is no confirmation: the move is bounded by ``--speed`` and supervised by ``--max-lag``, the destination is a pose the operator taught and `save` already clamped into the soft limits, diff --git a/mote_arm/mote_arm/arm_teleop.py b/mote_arm/mote_arm/arm_teleop.py index 7f83d97..6ad7821 100644 --- a/mote_arm/mote_arm/arm_teleop.py +++ b/mote_arm/mote_arm/arm_teleop.py @@ -63,6 +63,8 @@ PANIC_KEY = " " CLEAR_KEY = "z" SYNC_KEY = "0" +MODE_KEY = "m" +DEFAULT_STEP_RAD = 0.05 QUIT_KEYS = ("x", "\x03", "\x04") PUBLISH_RATE_HZ = 20.0 # Slow enough to read while a joint is moving, fast enough to look continuous. @@ -152,6 +154,32 @@ def driving(self, now: float) -> list[str]: if now - self._key_time.get(j.name, -1e9) <= self.key_timeout ] + def nudge(self, name: str, direction: float, size: float, now: float) -> bool: + """Advance one joint by exactly ``size`` radians. False if ignored. + + A held key auto-repeats and a terminal cannot tell a repeat from a fresh + press, so a repeat inside ``key_timeout`` is ignored: holding the key + steps once, and stepping again means releasing and pressing again. That + is what `arm-jog` did with a typed step and an Enter, without a second + keyboard path that has no rate limit, no deadman and no panic latch. + """ + if now - self._key_time.get(name, -1e9) <= self.key_timeout: + return False + self._key_time[name] = now + joint = self.cfg.joint(name) + current = self.pose.get(name, self.measured().get(name, 0.0)) + self.pose[name] = joint.clamp_rad(current + direction * size) + return True + + def settle_time(self, size: float) -> float: + """How long the arm needs to walk one step, at the rate limit. + + A step is offered for this long rather than once, because the deadman + would otherwise fire mid-travel and stop the arm short of the increment + that was asked for. + """ + return abs(size) / max(1e-6, self.mirror.limits.max_velocity) + def step(self, now: float, dt: float) -> bool: """Advance the commanded pose; True if an input is being held.""" live = False @@ -323,7 +351,7 @@ def _help(node: ArmTeleop) -> None: for problem in node.cfg.problems: _out(f" WARNING {problem}") _out(" SPACE panic (torque off) z clear 0 re-sync [ ] speed") - _out(" p all joint positions ? this help x quit") + _out(" m hold/step mode p all joint positions ? this help x quit") def _status(node: ArmTeleop, estopped: bool) -> None: @@ -336,11 +364,16 @@ def _status(node: ArmTeleop, estopped: bool) -> None: _out(f"[{state}] {parts}") -def _drive(node: ArmTeleop) -> None: +def _drive(node: ArmTeleop, step_size: float = DEFAULT_STEP_RAD) -> None: period = 1.0 / PUBLISH_RATE_HZ estopped = False idle_since = time.monotonic() last_line = 0.0 + # Hold mode moves while a key is held; step mode moves one increment per + # press. Step mode is what `arm-jog` was for, on the one keyboard path that + # has the rate limit, the deadman and the panic latch. + stepping = False + settle_until = 0.0 _out("arm teleop — the arm follows this pose. '?' for keys, 'x' to quit.") if not node.wait_for_states(): @@ -359,7 +392,23 @@ def _drive(node: ArmTeleop) -> None: return if key in node.keys: name, direction = node.keys[key] - node.press(name, direction, now) + if stepping: + if node.nudge(name, direction, step_size, now): + settle_until = now + node.settle_time(step_size) + _clear_live() + _out(f"{name} {direction * step_size:+.3f} rad") + else: + node.press(name, direction, now) + elif key == MODE_KEY: + stepping = not stepping + node.sync() + settle_until = 0.0 + _clear_live() + _out( + f"step mode: one {step_size:.3f} rad increment per press" + if stepping + else "hold mode: moves while a key is held" + ) elif key == PANIC_KEY: estopped = True node.set_estop(True) @@ -380,13 +429,20 @@ def _drive(node: ArmTeleop) -> None: elif key == "]": node.speed = min(1.0, node.speed + 0.05) _out(f"speed {node.speed:.2f} rad/s") - elif key in ("?", "h"): + elif key == "?": + # Not "h" as well: `h` drives joint 6 down, and the joint keys + # are matched first, so a help key there could never fire. _help(node) elif key == "p": _status(node, estopped) driving = node.driving(now) - live = node.step(now, period) and not estopped + if stepping: + # Offered until the arm has had time to walk the increment, or the + # deadman stops it half a step short of what was asked for. + live = now < settle_until and not estopped + else: + live = node.step(now, period) and not estopped if live: node.offer() idle_since = now @@ -451,6 +507,13 @@ def main() -> None: default=0.35, help="seconds after the last key repeat before it stops (default 0.35)", ) + parser.add_argument( + "--step", + type=float, + default=DEFAULT_STEP_RAD, + help=f"radians per press in step mode, toggled with 'm' " + f"(default {DEFAULT_STEP_RAD})", + ) parser.add_argument( "--demo", type=float, @@ -473,7 +536,7 @@ def main() -> None: if args.demo is not None: _demo(node, args.demo) else: - _interactive(node) + _interactive(node, args.step) except KeyboardInterrupt: pass finally: @@ -485,7 +548,7 @@ def main() -> None: ticker.join(timeout=2.0) -def _interactive(node: ArmTeleop) -> None: +def _interactive(node: ArmTeleop, step_size: float = DEFAULT_STEP_RAD) -> None: """Run the keyboard loop with the terminal in cbreak mode, and restore it.""" if not sys.stdin.isatty(): raise SystemExit( @@ -496,7 +559,7 @@ def _interactive(node: ArmTeleop) -> None: settings = termios.tcgetattr(sys.stdin) try: tty.setcbreak(sys.stdin.fileno()) - _drive(node) + _drive(node, step_size) finally: termios.tcsetattr(sys.stdin, termios.TCSADRAIN, settings) diff --git a/mote_arm/mote_arm/episode_record.py b/mote_arm/mote_arm/episode_record.py index d5a1812..9be3efe 100644 --- a/mote_arm/mote_arm/episode_record.py +++ b/mote_arm/mote_arm/episode_record.py @@ -10,7 +10,7 @@ The action is what reached ``arm_controller`` — the mirror's output, not the leader's pose: a policy replaces the thing that produces goals, so the goals are the thing to imitate. It is read off the trajectory topic rather than from the -mirror, so a session driven by ``arm-jog`` or by anything else records just as +teleop node, so a session driven by ``arm-pose`` or by anything else records just as well. Before the first goal of an episode arrives the action is the measured state — "stay where you are" is what the arm was, in fact, being told. diff --git a/mote_arm/mote_arm/jog.py b/mote_arm/mote_arm/jog.py deleted file mode 100644 index 1f011ec..0000000 --- a/mote_arm/mote_arm/jog.py +++ /dev/null @@ -1,204 +0,0 @@ -"""Interactive per-joint jog CLI for the SO-101 follower arm. - -A client of the ros2_control stack, not of the bus: it publishes single-point -trajectories to ``arm_controller/joint_trajectory`` and reads ``/joint_states`` -from the joint_state_broadcaster, so it never opens the serial port and cannot -contend with the wheels. Increments are clamped to the per-joint soft limits -from robot.yaml both here (for immediate feedback) and again in the hardware -(authoritative). - -"Torque" is controller activation. ``arm_controller`` is spawned *inactive*, so -the arm starts limp; activating it makes MoteHardware take hold of the arm's -current pose, and deactivating it drops torque. Jogging therefore activates on -demand, and exiting leaves the arm limp again. - -Start a stack that owns the bus first — ``pixi run arm`` on the bench, or -``pixi run robot`` / ``mapping`` during a mission — then ``pixi run arm-jog``. -""" - -from __future__ import annotations - -import threading -import time - -import rclpy -from rclpy.node import Node -from sensor_msgs.msg import JointState - -from mote_arm import cli, config -from mote_arm.config import JointSpec -from mote_arm.control import ArmControl - -# Trajectory speed for a jog move. Deliberately under the servos' own -# `moving_speed` cap (robot.yaml: 500 steps/s is ~0.77 rad/s) — a trajectory -# asking for more than the servo delivers just runs ahead of the hardware. -JOG_SPEED_RAD_S = 0.5 -MIN_MOVE_TIME_S = 0.5 - - -def next_target(current: float, step: float, joint: JointSpec) -> float: - """Advance ``current`` by ``step`` and clamp to the joint's soft limits.""" - return joint.clamp_rad(current + step) - - -def move_time(delta: float) -> float: - """Seconds to allow for a jog of ``delta`` radians.""" - return max(MIN_MOVE_TIME_S, abs(delta) / JOG_SPEED_RAD_S) - - -class JogClient(Node): - def __init__(self): - super().__init__("arm_jog") - self.declare_parameter("robot_yaml", "") - path = self.get_parameter("robot_yaml").get_parameter_value().string_value - self.cfg = config.ArmConfig.from_yaml_file(path) if path else config.load() - - self._measured: dict[str, float] = {} - self._target: dict[str, float] = {} - self._lock = threading.Lock() - - self.arm = ArmControl(self) - self.create_subscription(JointState, "joint_states", self._on_states, 10) - - def _on_states(self, msg: JointState) -> None: - with self._lock: - for name, pos in zip(msg.name, msg.position): - self._measured[name] = pos - - def measured(self, name: str) -> float | None: - with self._lock: - return self._measured.get(name) - - def base_for(self, joint: JointSpec) -> float: - """Base angle for the next jog: the last commanded target if this joint - has been jogged, otherwise the live measured position (so the first jog - steps from where the arm actually is, not from an assumed zero).""" - if joint.name in self._target: - return self._target[joint.name] - meas = self.measured(joint.name) - return meas if meas is not None else 0.0 - - def wait_for_states(self, timeout: float = 5.0) -> bool: - """Block briefly until /joint_states carries an arm joint. - - The joint_state_broadcaster publishes the wheels too, so waiting for any - message at all would succeed even with the arm absent from the stack. - """ - wanted = set(self.cfg.names) - deadline = time.time() + timeout - while time.time() < deadline: - with self._lock: - if wanted & self._measured.keys(): - return True - time.sleep(0.05) - return False - - def send(self, joint: JointSpec, rad: float) -> None: - """Command one joint, taking hold of the arm first if it is still limp.""" - measured = self.measured(joint.name) - delta = rad - measured if measured is not None else rad - if self.arm.send({joint.name: rad}, move_time(delta)): - self._target[joint.name] = rad - - -HELP = """ -Commands: - select joint by number - + / - jog selected joint by +step / -step - step set jog step (default 0.05 rad) - zero move selected joint to 0 rad (mid-travel, NOT the rest pose) - torque on|off hold (activate arm_controller) / limp (deactivate it) - status print all joints - help show this help - quit limp the arm and exit -""".rstrip() - - -def _print_status(node: JogClient, selected: int, step: float) -> None: - print(f"\nstep = {step:.3f} rad arm is {'HOLDING' if node.arm.held else 'LIMP'}") - for i, joint in enumerate(node.cfg.joints): - meas = node.measured(joint.name) - meas_s = f"{meas:+.3f}" if meas is not None else " ? " - marker = "->" if i == selected else " " - print( - f" {marker} [{i}] {joint.name:<14} meas={meas_s} rad " - f"target={node.base_for(joint):+.3f} " - f"limits=[{joint.min_rad:+.2f}, {joint.max_rad:+.2f}]" - ) - - -def _repl(node: JogClient) -> None: - selected = 0 - step = 0.05 - print("SO-101 arm jog. Type 'help' for commands. Arm starts LIMP.") - if not node.wait_for_states(): - print( - "warning: no arm joints on /joint_states — is a stack that owns the " - "bus running (`pixi run arm`, or `pixi run robot`)?" - ) - _print_status(node, selected, step) - while True: - try: - line = input("jog> ").strip() - except EOFError: - break - if not line: - continue - parts = line.split() - cmd = parts[0].lower() - joint = node.cfg.joints[selected] - - if cmd in ("q", "quit", "exit"): - break - elif cmd in ("help", "h", "?"): - print(HELP) - elif cmd.isdigit(): - idx = int(cmd) - if 0 <= idx < len(node.cfg.joints): - selected = idx - else: - print(f"no joint {idx}") - _print_status(node, selected, step) - elif cmd == "step" and len(parts) == 2: - try: - step = abs(float(parts[1])) - except ValueError: - print("bad step") - elif cmd in ("+", "-"): - delta = step if cmd == "+" else -step - tgt = next_target(node.base_for(joint), delta, joint) - node.send(joint, tgt) - print(f"{joint.name} -> {tgt:+.3f} rad") - elif cmd in ("zero", "home"): - if cmd == "home": - # "home" is the name of a taught rest pose; 0 rad is the middle - # of the joint's travel, a different place. Renamed rather than - # removed, so the old reflex still works and says so. - print("note: 'home' is now 'zero' — 0 rad is mid-travel.") - tgt = joint.clamp_rad(0.0) - node.send(joint, tgt) - print(f"{joint.name} -> {tgt:+.3f} rad (zero)") - elif cmd == "torque" and len(parts) == 2: - node.arm.set_holding(parts[1].lower() in ("on", "true", "1", "hold")) - elif cmd == "status": - _print_status(node, selected, step) - else: - print("unknown command; type 'help'") - - -def main() -> None: - rclpy.init() - node = JogClient() - spinner = cli.spin_background(node) - try: - _repl(node) - except KeyboardInterrupt: - pass - finally: - print("\nlimping arm (deactivating arm_controller) and exiting...") - node.arm.set_holding(False) - cli.shutdown(node, spinner) - - -if __name__ == "__main__": - main() diff --git a/mote_arm/mote_arm/mock_arm.py b/mote_arm/mote_arm/mock_arm.py index de86381..c401303 100644 --- a/mote_arm/mote_arm/mock_arm.py +++ b/mote_arm/mote_arm/mock_arm.py @@ -9,7 +9,7 @@ subscribes arm_controller/joint_trajectory (trajectory_msgs/JointTrajectory) serves controller_manager/switch_controller -so `mote_arm.control.ArmControl` — and therefore the mirror, `arm-jog`, +so `mote_arm.control.ArmControl` — and therefore `arm-teleop`, `arm-pose` and episode replay — cannot tell the difference. It also optionally publishes a synthetic `image_raw/compressed` whose content tracks the first joint, so a recorded episode has camera frames that actually change and an diff --git a/mote_arm/setup.py b/mote_arm/setup.py index 454ed4e..486b205 100644 --- a/mote_arm/setup.py +++ b/mote_arm/setup.py @@ -20,7 +20,6 @@ license="Apache-2.0", entry_points={ "console_scripts": [ - "jog = mote_arm.jog:main", "arm_setup = mote_arm.arm_setup:main", "arm_pose = mote_arm.arm_pose:main", "arm_teleop = mote_arm.arm_teleop:main", diff --git a/mote_arm/test/test_control.py b/mote_arm/test/test_control.py index d0f1f4e..ea3bd01 100644 --- a/mote_arm/test/test_control.py +++ b/mote_arm/test/test_control.py @@ -6,9 +6,7 @@ robot's clock rather than on the operator's. """ -from mote_arm.config import JointSpec from mote_arm.control import ARM_CONTROLLER, TRAJECTORY_TOPIC, duration_msg, trajectory -from mote_arm.jog import MIN_MOVE_TIME_S, move_time, next_target def test_trajectory_names_the_joints_it_moves(): @@ -43,19 +41,3 @@ def test_trajectory_carries_the_move_time(): def test_topic_is_the_controller_s_own(): assert TRAJECTORY_TOPIC == f"{ARM_CONTROLLER}/joint_trajectory" - - -def test_move_time_scales_with_distance(): - # A jog must not ask the arm to travel further in the same time; the servos - # have their own speed cap and a trajectory faster than it just runs ahead. - assert move_time(2.0) > move_time(0.5) - - -def test_move_time_has_a_floor(): - assert move_time(0.0) == MIN_MOVE_TIME_S - assert move_time(-0.001) == MIN_MOVE_TIME_S - - -def test_jog_step_is_still_clamped_before_it_is_sent(): - j = JointSpec("j", 1, min_rad=-1.0, max_rad=1.0) - assert next_target(0.98, 0.05, j) == 1.0 diff --git a/mote_arm/test/test_control_holding.py b/mote_arm/test/test_control_holding.py index 3ecd7ac..4f9a128 100644 --- a/mote_arm/test/test_control_holding.py +++ b/mote_arm/test/test_control_holding.py @@ -116,7 +116,7 @@ def test_a_fresh_client_activates_a_controller_that_is_inactive(): def test_a_fresh_client_deactivates_a_controller_another_process_left_holding(): - """`jog` says it limps on exit; assuming False meant it silently did not.""" + """A client that says it limps on exit: assuming False meant it silently did not.""" arm, clients, _ = control(state="active") assert arm.set_holding(False) is True assert clients[SWITCH_SERVICE].requests[0].deactivate_controllers == [ diff --git a/mote_arm/test/test_jog.py b/mote_arm/test/test_jog.py deleted file mode 100644 index e606975..0000000 --- a/mote_arm/test/test_jog.py +++ /dev/null @@ -1,22 +0,0 @@ -"""Unit tests for the jog step maths (soft-limit clamped increments).""" - -from mote_arm.config import JointSpec -from mote_arm.jog import next_target - - -def test_step_within_limits(): - j = JointSpec("j", 1, min_rad=-1.0, max_rad=1.0) - assert abs(next_target(0.0, 0.05, j) - 0.05) < 1e-9 - assert abs(next_target(0.0, -0.05, j) + 0.05) < 1e-9 - - -def test_step_clamped_at_upper_limit(): - j = JointSpec("j", 1, min_rad=-1.0, max_rad=1.0) - assert next_target(0.98, 0.05, j) == 1.0 - # repeated jogs never exceed the limit - assert next_target(1.0, 0.05, j) == 1.0 - - -def test_step_clamped_at_lower_limit(): - j = JointSpec("j", 1, min_rad=-1.0, max_rad=1.0) - assert next_target(-0.98, -0.05, j) == -1.0 diff --git a/mote_arm/test/test_teleop_node.py b/mote_arm/test/test_teleop_node.py index b0e90eb..1622e09 100644 --- a/mote_arm/test/test_teleop_node.py +++ b/mote_arm/test/test_teleop_node.py @@ -172,3 +172,53 @@ def test_clearing_panic_lets_the_arm_move_again(stack): stack.panic(False) stack.run(0.6) assert stack.at("elbow_flex") > stopped + 0.05 + + +# --- step mode: what `arm-jog` was for, on the path that has the safety rules + + +def test_a_press_advances_the_pose_by_exactly_one_step(stack): + stack.teleop.sync() + before = stack.teleop.pose["elbow_flex"] + assert stack.teleop.nudge("elbow_flex", +1.0, 0.05, 100.0) is True + assert stack.teleop.pose["elbow_flex"] == pytest.approx(before + 0.05) + + +def test_a_key_repeat_does_not_step_again(stack): + """A terminal cannot tell a repeat from a press, so holding steps once.""" + stack.teleop.sync() + assert stack.teleop.nudge("elbow_flex", +1.0, 0.05, 100.0) is True + stepped = stack.teleop.pose["elbow_flex"] + for repeat in (100.03, 100.1, 100.3): + assert stack.teleop.nudge("elbow_flex", +1.0, 0.05, repeat) is False + assert stack.teleop.pose["elbow_flex"] == pytest.approx(stepped) + + +def test_releasing_and_pressing_again_steps_again(stack): + stack.teleop.sync() + stack.teleop.nudge("elbow_flex", +1.0, 0.05, 100.0) + later = 100.0 + stack.teleop.key_timeout + 0.01 + assert stack.teleop.nudge("elbow_flex", +1.0, 0.05, later) is True + + +def test_a_step_is_clamped_like_any_other_command(stack): + stack.teleop.sync() + for press in range(50): + stack.teleop.nudge("wrist_roll", +1.0, 0.05, 100.0 + press) + assert stack.teleop.pose["wrist_roll"] == pytest.approx(0.1) + + +def test_a_step_is_offered_long_enough_for_the_arm_to_walk_it(stack): + """Offered once, the deadman would stop the arm short of the increment.""" + limit = stack.teleop.mirror.limits.max_velocity + assert stack.teleop.settle_time(0.05) == pytest.approx(0.05 / limit) + assert stack.teleop.settle_time(0.5) > stack.teleop.key_timeout + + +def test_stepping_moves_the_arm_by_about_the_step(stack): + start = stack.at("elbow_flex") + stack.teleop.sync() + stack.teleop.nudge("elbow_flex", +1.0, 0.1, time.monotonic()) + stack.pose = dict(stack.teleop.pose) + stack.run(stack.teleop.settle_time(0.1) + 0.4) + assert stack.at("elbow_flex") == pytest.approx(start + 0.1, abs=0.03) diff --git a/mote_bringup/launch/arm_launch.py b/mote_bringup/launch/arm_launch.py index ab008b0..36356b3 100644 --- a/mote_bringup/launch/arm_launch.py +++ b/mote_bringup/launch/arm_launch.py @@ -11,7 +11,7 @@ `controllers.yaml` the mission uses — including this robot's own arm calibration — so what you jog on the bench is what runs on the robot. No diff_drive_controller is loaded, so nothing here can drive the wheels, and the arm controller is loaded *inactive* — the arm is limp until -`pixi run arm-jog` (or `switch_controllers --activate arm_controller`) asks it +`pixi run arm-teleop` (or `switch_controllers --activate arm_controller`) asks it to hold. Teleop is `pixi run arm-teleop` in a second terminal beside this one; it is one diff --git a/mote_bringup/mote_bringup/launch_utils.py b/mote_bringup/mote_bringup/launch_utils.py index 158cf5e..dc5766f 100644 --- a/mote_bringup/mote_bringup/launch_utils.py +++ b/mote_bringup/mote_bringup/launch_utils.py @@ -35,7 +35,7 @@ # claims its command interfaces, and for the arm that is what enables servo # torque (MoteHardware::perform_command_mode_switch) — so an arm nobody has # asked to move stays limp, exactly as it did under the standalone driver. -# `pixi run arm-jog` (or the task layer) activates it on demand. +# `pixi run arm-teleop` (or the task layer) activates it on demand. INACTIVE_CONTROLLERS = ("arm_controller",) diff --git a/pixi.toml b/pixi.toml index df8f748..3509756 100644 --- a/pixi.toml +++ b/pixi.toml @@ -143,10 +143,9 @@ node-cpu = "python mote_bringup/tools/node_cpu.py" # SO-101 arm bench stack: the same ros2_control bring-up a mission uses, without # the lidar/camera/Nav2. During a mission the arm is already there (it lives in # mote_hardware, which owns the shared servo bus) — this is for bench work only. -# Jog it interactively with `pixi run arm-jog`, in either case. +# Drive it with `pixi run arm-teleop`, in either case. arm = "ros2 launch mote_bringup arm_launch.py" -arm-jog = "ros2 run mote_arm jog" -# Teach/replay named arm poses (save is read-only; go asks before moving). +# Teach/replay named arm poses. arm-pose = "ros2 run mote_arm arm_pose" # Everything that configures the arm's servos, behind one command: check, # calibrate, gains, offsets, limits. Opens the bus directly, so run it with the From c656f939c4589fc2892e0c5ce8fedc0f634d535f Mon Sep 17 00:00:00 2001 From: Michael Johnson Date: Thu, 3 Sep 2026 13:23:34 +0100 Subject: [PATCH 21/22] Stop naming the arm we do not have "Virtual-leader teleop" is LeRobot's vocabulary: there, an operator puppets a physical *leader* arm and the follower mirrors it. There is no leader arm here and no plan to buy one, so the phrase names a piece of hardware that does not exist, in order to describe a keyboard. It is keyboard teleop. The prose says so, and so do the internals that carried the framing furthest: `LeaderMirror` -> `PoseFollower`, `MirrorLimits` -> `FollowLimits`, `on_leader` -> `on_command`. What the module does is take a commanded pose, apply the safety rules and emit goals; none of that needs a leader to describe. The word survives in exactly two places, both deliberate: the commit trail, and the paragraphs in TELEOP.md and CLAUDE.md explaining what `virtual_leader` and `arm_mirror` were and why they are one node now. 286 tests pass; the rename is mechanical and the behaviour is unchanged. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_017hfWyNtkVh16hyvCK8K9cd --- mote_arm/BENCH.md | 2 +- mote_arm/TELEOP.md | 2 +- mote_arm/mote_arm/arm_teleop.py | 20 +++---- mote_arm/mote_arm/diagnostics.py | 2 +- mote_arm/mote_arm/episode_replay.py | 2 +- mote_arm/mote_arm/teleop.py | 82 ++++++++++++++--------------- mote_arm/test/test_teleop.py | 32 +++++------ mote_arm/tools/bench_teleop.sh | 10 ++-- 8 files changed, 76 insertions(+), 76 deletions(-) diff --git a/mote_arm/BENCH.md b/mote_arm/BENCH.md index 00c2907..aacf292 100644 --- a/mote_arm/BENCH.md +++ b/mote_arm/BENCH.md @@ -373,7 +373,7 @@ exception` on stderr. A `134` is the destroy-while-spinning abort (see README, "Exits and arguments"); it means the tool did its job and then crashed on the way out. -## Step 8 — virtual-leader teleop, recording and replay +## Step 8 — keyboard teleop, recording and replay The teleop path has its own guided session, because it needs three terminals and because three of its checks are observations no script can make (the arm diff --git a/mote_arm/TELEOP.md b/mote_arm/TELEOP.md index 7b28df3..43077ae 100644 --- a/mote_arm/TELEOP.md +++ b/mote_arm/TELEOP.md @@ -283,7 +283,7 @@ The unit tests are the load-bearing ones: every safety rule is decided in ## Other frontends -The replaceable part is `mote_arm/teleop.py`: `LeaderMirror` holds every safety +The replaceable part is `mote_arm/teleop.py`: `PoseFollower` holds every safety rule — clamping, the rate limit, the deadman, the panic latch — with no ROS in it. A gamepad, a slider GUI or a script becomes a frontend by importing that and feeding it poses, exactly as `ArmTeleop` does; what it must not do is command diff --git a/mote_arm/mote_arm/arm_teleop.py b/mote_arm/mote_arm/arm_teleop.py index 6ad7821..3de6cbc 100644 --- a/mote_arm/mote_arm/arm_teleop.py +++ b/mote_arm/mote_arm/arm_teleop.py @@ -1,7 +1,7 @@ """Keyboard teleoperation of the SO-101 arm. One process, one node. The keyboard moves a commanded pose; every safety rule -in `mote_arm.teleop.LeaderMirror` is applied to it — clamping, rate limiting, +in `mote_arm.teleop.PoseFollower` is applied to it — clamping, rate limiting, the deadman, the panic latch — and the result goes to `arm_controller` through `mote_arm.control`. Nothing here opens the servo bus. @@ -55,7 +55,7 @@ from mote_arm import cli, config, teleop from mote_arm.control import ArmControl from mote_arm.diagnostics import Diagnostics -from mote_arm.teleop import ESTOPPED, HOLDING, TRACKING, LeaderMirror, MirrorLimits +from mote_arm.teleop import ESTOPPED, HOLDING, TRACKING, PoseFollower, FollowLimits # Key pairs in joint order: the top row raises a joint, the home row lowers it. KEY_PAIRS = [("q", "a"), ("w", "s"), ("e", "d"), ("r", "f"), ("t", "g"), ("y", "h")] @@ -78,8 +78,8 @@ def __init__(self, speed: float, key_timeout: float): super().__init__("arm_teleop") self.declare_parameter("robot_yaml", "") self.declare_parameter("rate", 20.0) - self.declare_parameter("max_velocity", MirrorLimits.max_velocity) - self.declare_parameter("deadman_timeout", MirrorLimits.deadman_timeout) + self.declare_parameter("max_velocity", FollowLimits.max_velocity) + self.declare_parameter("deadman_timeout", FollowLimits.deadman_timeout) # `pixi run arm-teleop --ros-args -p diagnose:=true` self.declare_parameter("diagnose", False) @@ -96,9 +96,9 @@ def __init__(self, speed: float, key_timeout: float): self._key_time: dict[str, float] = {} self._estop_requested = False - self.mirror = LeaderMirror( + self.mirror = PoseFollower( self.cfg.joints, - MirrorLimits( + FollowLimits( max_velocity=self.get_parameter("max_velocity").value, deadman_timeout=self.get_parameter("deadman_timeout").value, ), @@ -199,11 +199,11 @@ def offer(self) -> None: Named for what it does rather than for a topic: this used to be a publish, and the mirror on the other end was free to refuse it. It still - is — `LeaderMirror` clamps, rate-limits and may be latched off. + is — `PoseFollower` clamps, rate-limits and may be latched off. """ if self.diagnostics is not None: - self.diagnostics.on_leader(time.monotonic()) - self.mirror.on_leader(dict(self.pose), self._now()) + self.diagnostics.on_command(time.monotonic()) + self.mirror.on_command(dict(self.pose), self._now()) def set_estop(self, engaged: bool) -> None: """Latch or clear the panic. Acted on by the tick, never from here. @@ -328,7 +328,7 @@ def _driving_line(node: ArmTeleop, names: list[str]) -> str: # The arm is being asked for something it is not doing. Saying so # here is the difference between "why is nothing happening" and # knowing the command is fine and the joint is not moving. - if now == now and abs(target - now) > MirrorLimits.max_lag: + if now == now and abs(target - now) > FollowLimits.max_lag: line += " NOT FOLLOWING" parts.append(line) return " " + " ".join(parts) diff --git a/mote_arm/mote_arm/diagnostics.py b/mote_arm/mote_arm/diagnostics.py index 564641a..7d4d29a 100644 --- a/mote_arm/mote_arm/diagnostics.py +++ b/mote_arm/mote_arm/diagnostics.py @@ -45,7 +45,7 @@ def _reset(self, now: float) -> None: self._measured0 = self._node.mirror.measured self.leader_stamps = [] - def on_leader(self, now: float) -> None: + def on_command(self, now: float) -> None: self.leader_stamps.append(now) def tick(self, now: float) -> None: diff --git a/mote_arm/mote_arm/episode_replay.py b/mote_arm/mote_arm/episode_replay.py index 4846998..8bf5da8 100644 --- a/mote_arm/mote_arm/episode_replay.py +++ b/mote_arm/mote_arm/episode_replay.py @@ -22,7 +22,7 @@ hardware, so an episode recorded before a limit was tightened cannot replay outside the current envelope. -Stop the virtual leader before replaying — two things commanding +Stop teleop before replaying — two things commanding ``arm_controller`` would fight over the arm. """ diff --git a/mote_arm/mote_arm/teleop.py b/mote_arm/mote_arm/teleop.py index 0b89418..6772a28 100644 --- a/mote_arm/mote_arm/teleop.py +++ b/mote_arm/mote_arm/teleop.py @@ -1,20 +1,20 @@ -"""The virtual leader's follow rule, with no ROS and no hardware attached. +"""The follow rule, with no ROS and no hardware attached. -Teleoperation here is leader-follower without a leader arm: a *virtual leader* -is a pose held in software that an operator moves, and the follower mirrors it. -This module is the mirroring itself — everything that decides whether the real -arm may move, and how far, in one place that a unit test can drive: +Teleoperation here has no leader arm: the operator moves a *commanded pose* held +in software and the arm follows it. This module is the following itself — +everything that decides whether the real arm may move, and how far, in one place +that a unit test can drive: - * **clamping** — a leader pose outside the joint's soft limits is clamped + * **clamping** — a commanded pose outside the joint's soft limits is clamped before it ever becomes a goal (the driver clamps again; this one exists so the operator sees the limit rather than discovering it downstream), - * **rate limiting** — the commanded pose advances towards the leader by at - most ``max_velocity * dt``, so a leader that jumps (a slider dragged, a - frontend restarted at a different pose) produces a ramp, never a lunge, - * **the deadman** — the leader's *liveness* is the deadman. A frontend - publishes only while the operator is actually driving it, so input that - stops — a released key, a closed window, an SSH session dropped mid-move — - all arrive as the same thing: no fresh leader pose. Motion then halts, + * **rate limiting** — the goal advances towards the commanded pose by at most + ``max_velocity * dt``, so a command that jumps (a slider dragged, a frontend + restarted at a different pose) produces a ramp, never a lunge, + * **the deadman** — the command's *liveness* is the deadman. A frontend offers + a pose only while the operator is actually driving it, so input that stops — + a released key, a closed window, an SSH session dropped mid-move — all + arrive as the same thing: no fresh pose. Motion then halts, * **the panic latch** — an engaged e-stop suppresses every goal until it is explicitly cleared, so torque coming back on cannot restart the move. @@ -33,14 +33,14 @@ @dataclass(frozen=True) -class MirrorLimits: - """How fast the follower may chase the leader, and when it stops trying.""" +class FollowLimits: + """How fast the arm may chase the commanded pose, and when it stops trying.""" - # Radians per second the commanded pose may advance. Deliberately at or - # above the virtual leader's own speed, so the follower is never left with - # a backlog of leader motion to work through after the operator stops. + # Radians per second the goal may advance. Deliberately at or above the + # frontend's own speed, so the arm is never left with a backlog of commanded + # motion to work through after the operator stops. max_velocity: float = 0.5 - # Seconds without a leader pose before motion halts. Long enough to cover a + # Seconds without a fresh pose before motion halts. Long enough to cover a # terminal's key-repeat gap, short enough that a released key stops the arm # while it is still obviously connected to the key. deadman_timeout: float = 0.4 @@ -63,26 +63,26 @@ def __post_init__(self) -> None: raise ValueError("max_lag must be positive") -# What the mirror is doing, for logging and for tests to assert on. +# What the follower is doing, for logging and for tests to assert on. TRACKING = "tracking" -HOLDING = "holding" # deadman: no fresh leader pose +HOLDING = "holding" # deadman: no fresh commanded pose ESTOPPED = "estopped" -WAITING = "waiting" # no follower state yet, so nothing is safe to command +WAITING = "waiting" # no measured state yet, so nothing is safe to command -class LeaderMirror: - """Turns virtual-leader poses into rate-limited, clamped follower goals.""" +class PoseFollower: + """Turns commanded poses into rate-limited, clamped goals for the arm.""" def __init__( self, joints: Sequence[JointSpec], - limits: MirrorLimits | None = None, + limits: FollowLimits | None = None, ): self._joints = {j.name: j for j in joints} - self.limits = limits or MirrorLimits() + self.limits = limits or FollowLimits() self._measured: dict[str, float] = {} - self._leader: dict[str, float] = {} - self._leader_stamp: float | None = None + self._command: dict[str, float] = {} + self._command_stamp: float | None = None self._commanded: dict[str, float] = {} self._estop = False # True when the commanded pose is not trustworthy as a starting point — @@ -112,14 +112,14 @@ def measured(self) -> dict[str, float]: """The pose last reported by the arm — read-only, for diagnostics.""" return dict(self._measured) - def on_leader(self, pose: Mapping[str, float], now: float) -> None: - """Record a virtual-leader pose. Unknown joint names are ignored.""" - self._leader = {n: v for n, v in pose.items() if n in self._joints} - if self._leader: - self._leader_stamp = now + def on_command(self, pose: Mapping[str, float], now: float) -> None: + """Record a commanded pose. Unknown joint names are ignored.""" + self._command = {n: v for n, v in pose.items() if n in self._joints} + if self._command: + self._command_stamp = now def on_measured(self, pose: Mapping[str, float]) -> None: - """Record where the follower actually is.""" + """Record where the arm actually is.""" for name, value in pose.items(): if name in self._joints: self._measured[name] = value @@ -151,8 +151,8 @@ def update(self, now: float, dt: float) -> dict[str, float] | None: return None stale = ( - self._leader_stamp is None - or (now - self._leader_stamp) > self.limits.deadman_timeout + self._command_stamp is None + or (now - self._command_stamp) > self.limits.deadman_timeout ) if stale: if self.state == TRACKING: @@ -174,7 +174,7 @@ def update(self, now: float, dt: float) -> dict[str, float] | None: max_step = self.limits.max_velocity * max(0.0, dt) goal: dict[str, float] = {} stalled: list[str] = [] - for name, target in self._leader.items(): + for name, target in self._command.items(): joint = self._joints[name] start = self._commanded.get(name, self._measured.get(name)) if start is None: @@ -200,10 +200,10 @@ def update(self, now: float, dt: float) -> dict[str, float] | None: def sync_pose( measured: Mapping[str, float], joints: Sequence[JointSpec] ) -> dict[str, float]: - """The virtual leader's pose when it re-syncs to the arm: measured, clamped. + """The commanded pose when it re-syncs to the arm: measured, then clamped. - A leader re-synced to a follower sitting fractionally outside its soft band - (limits are taught, and a servo droops) would otherwise hand back a pose the - mirror immediately clamps, showing a leader that cannot be where it says. + Re-syncing to an arm sitting fractionally outside its soft band (limits are + taught, and a servo droops) would otherwise hand back a pose the follower + immediately clamps, showing a command that cannot be where it says. """ return {j.name: j.clamp_rad(measured[j.name]) for j in joints if j.name in measured} diff --git a/mote_arm/test/test_teleop.py b/mote_arm/test/test_teleop.py index 468d48a..47f7b65 100644 --- a/mote_arm/test/test_teleop.py +++ b/mote_arm/test/test_teleop.py @@ -1,6 +1,6 @@ """The follow rule: clamping, rate limiting, the deadman, and the panic latch. -Every safety property of virtual-leader teleop is decided in ``LeaderMirror``, +Every safety property of keyboard teleop is decided in ``PoseFollower``, so it is all checked here — with no bus, no driver and no terminal. """ @@ -12,8 +12,8 @@ HOLDING, TRACKING, WAITING, - LeaderMirror, - MirrorLimits, + PoseFollower, + FollowLimits, sync_pose, ) @@ -21,30 +21,30 @@ JointSpec(name="elbow_flex", id=3, min_rad=-1.0, max_rad=1.0), JointSpec(name="wrist_roll", id=5, min_rad=-0.1, max_rad=0.1), ) -LIMITS = MirrorLimits(max_velocity=1.0, deadman_timeout=0.4) +LIMITS = FollowLimits(max_velocity=1.0, deadman_timeout=0.4) DT = 0.05 -def mirror(**kwargs) -> LeaderMirror: - return LeaderMirror(JOINTS, MirrorLimits(**{**LIMITS.__dict__, **kwargs})) +def mirror(**kwargs) -> PoseFollower: + return PoseFollower(JOINTS, FollowLimits(**{**LIMITS.__dict__, **kwargs})) def drive( - m: LeaderMirror, leader: dict, seconds: float, start: float = 0.0 + m: PoseFollower, leader: dict, seconds: float, start: float = 0.0 ) -> dict | None: """Feed a steady leader pose for ``seconds`` and return the last goal.""" goal = None ticks = int(round(seconds / DT)) for i in range(ticks): now = start + i * DT - m.on_leader(leader, now) + m.on_command(leader, now) goal = m.update(now, DT) return goal def test_nothing_is_commanded_before_the_arm_reports(): m = mirror() - m.on_leader({"elbow_flex": 0.5}, 0.0) + m.on_command({"elbow_flex": 0.5}, 0.0) assert m.update(0.0, DT) is None assert m.state == WAITING @@ -52,7 +52,7 @@ def test_nothing_is_commanded_before_the_arm_reports(): def test_goal_advances_at_the_rate_limit(): m = mirror(max_velocity=1.0) m.on_measured({"elbow_flex": 0.0, "wrist_roll": 0.0}) - m.on_leader({"elbow_flex": 1.0}, 0.0) + m.on_command({"elbow_flex": 1.0}, 0.0) # One tick of 50 ms at 1 rad/s is 0.05 rad, however far away the leader is. assert m.update(0.0, DT)["elbow_flex"] == pytest.approx(0.05) assert m.update(DT, DT)["elbow_flex"] == pytest.approx(0.10) @@ -152,9 +152,9 @@ def test_sync_pose_clamps_a_drooping_arm_into_the_band(): def test_limits_must_be_positive(): with pytest.raises(ValueError): - MirrorLimits(max_velocity=0.0) + FollowLimits(max_velocity=0.0) with pytest.raises(ValueError): - MirrorLimits(deadman_timeout=-1.0) + FollowLimits(deadman_timeout=-1.0) def test_the_command_never_runs_away_from_an_arm_that_is_not_moving(): @@ -171,7 +171,7 @@ def test_the_command_never_runs_away_from_an_arm_that_is_not_moving(): goal = None for i in range(60): # 3 s of a held key, with the arm never moving now = i * DT - m.on_leader({"elbow_flex": 1.0}, now) + m.on_command({"elbow_flex": 1.0}, now) goal = m.update(now, DT) m.on_measured({"elbow_flex": 0.0}) @@ -184,7 +184,7 @@ def test_a_following_arm_is_never_reported_as_stalled(): m.on_measured({"elbow_flex": 0.0}) for i in range(40): now = i * DT - m.on_leader({"elbow_flex": 1.0}, now) + m.on_command({"elbow_flex": 1.0}, now) goal = m.update(now, DT) # The arm keeps up, trailing by the ordinary droop. m.on_measured({"elbow_flex": goal["elbow_flex"] - 0.02}) @@ -196,7 +196,7 @@ def test_the_command_resumes_once_the_arm_moves_again(): m = mirror(max_velocity=1.0, max_lag=0.15) m.on_measured({"elbow_flex": 0.0}) for i in range(40): - m.on_leader({"elbow_flex": 1.0}, i * DT) + m.on_command({"elbow_flex": 1.0}, i * DT) m.update(i * DT, DT) assert m.stalled == ["elbow_flex"] @@ -206,7 +206,7 @@ def test_the_command_resumes_once_the_arm_moves_again(): for i in range(10): now = 3.0 + i * DT m.on_measured({"elbow_flex": m.commanded["elbow_flex"] - 0.02}) - m.on_leader({"elbow_flex": 1.0}, now) + m.on_command({"elbow_flex": 1.0}, now) goal = m.update(now, DT) assert goal["elbow_flex"] > 0.5 assert m.stalled == [] diff --git a/mote_arm/tools/bench_teleop.sh b/mote_arm/tools/bench_teleop.sh index 367c786..11855c1 100755 --- a/mote_arm/tools/bench_teleop.sh +++ b/mote_arm/tools/bench_teleop.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash -# Guided bench session for virtual-leader teleop: teleop -> record -> inspect +# Guided bench session for keyboard teleop: teleop -> record -> inspect # -> replay, with the safety behaviours demonstrated on the way. # # This is the hardware counterpart of `pixi run arm-teleop-test`, which runs the @@ -53,7 +53,7 @@ check() { FAILURES=0 mkdir -p "$CAPTURE" : >"$REPORT" -note "mote_arm virtual-leader bench session" +note "mote_arm keyboard teleop bench session" note "date: $(date -Is)" note "capture: $CAPTURE" @@ -156,7 +156,7 @@ echo # is not gets a replay that loses to the mirror and reports a stall — which # reads exactly like the arm failing, and is the one failure here that is not # about the arm at all. -echo -n " waiting for the virtual leader to exit" +echo -n " waiting for teleop to exit" for _ in $(seq 60); do ros2 node list 2>/dev/null | grep -q arm_teleop || break echo -n "." @@ -164,10 +164,10 @@ for _ in $(seq 60); do done echo if ros2 node list 2>/dev/null | grep -q arm_teleop; then - note " SKIP replay: the virtual leader is still running after 2 minutes" + note " SKIP replay: teleop is still running after 2 minutes" FAILURES=$((FAILURES + 1)) else - note " the virtual leader has exited; replaying" + note " teleop has exited; replaying" ros2 run mote_arm episode_replay "$CAPTURE" --episode 0 --speed-scale 0.25 2>&1 | tee -a "$REPORT" ask "the arm retraced the recorded motion" fi From d040e3c730a50688ed0a66d1721c4b5662fdaace Mon Sep 17 00:00:00 2001 From: Michael Johnson Date: Thu, 3 Sep 2026 15:03:20 +0100 Subject: [PATCH 22/22] Delete the bench rig; the checks it asked about belong in BENCH.md `arm-bench-teleop` was a test rig for one ticket: 180 lines of bash that prompted for six observations and wrote a report. It never ran to completion, and two of its own defects were fixed tonight rather than any of the arm's -- the terminal confusion, and a traceback when the recorder was quit before its first episode. A one-off rig accruing maintenance is the worst kind. What it was for survives, because it was never the script: three of the checks are observations no test can make -- the arm stopping at a limit, halting on a released key, going limp on panic -- and those are now a numbered list in BENCH.md step 8, beside every other bench step, with the record/check/replay commands under them. The one thing lost is the pasteable report, which mattered once. Step 8 also still said `pixi run arm mirror:=true`, three commits after that argument stopped existing, and now says what to run: two terminals, `arm` and `arm-teleop`. It gained the step-mode check, which nothing covered. `check_capture.py` stays -- `arm-teleop-test` uses it, and step 8 now calls it directly. 282 tests pass. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_017hfWyNtkVh16hyvCK8K9cd --- CLAUDE.md | 3 +- mote_arm/BENCH.md | 51 +++++---- mote_arm/TELEOP.md | 1 - mote_arm/tools/bench_teleop.sh | 183 --------------------------------- pixi.toml | 3 - 5 files changed, 33 insertions(+), 208 deletions(-) delete mode 100755 mote_arm/tools/bench_teleop.sh diff --git a/CLAUDE.md b/CLAUDE.md index d06cd53..deae6f1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -32,7 +32,6 @@ pixi run arm-mock # The arm control stack's interface, no hardware (+ --ca pixi run arm-record # Record teleop episodes into $MOTE_HOME/episodes pixi run arm-replay # Replay a recorded episode on the arm, gated pixi run arm-teleop-test # Headless teleop->record->replay loop vs the mock arm -pixi run arm-bench-teleop # Guided hardware teleop session (needs a human) pixi run sync # rsync project to Pi at SSH host 'mote' pixi run setup # One-time Pi setup: udev + wifi + systemd (needs sudo) pixi run udev # Install udev rules + dialout group (needs sudo) @@ -962,7 +961,7 @@ section. Contains: ros2_control surface — trajectory topic plus `switch_controller` — with no bus and an optional pure-zlib synthetic camera, so the whole loop runs on a workstation: `pixi run arm-teleop-test` drives it headless and is the - pre-bench gate; `pixi run arm-bench-teleop` is the guided hardware session. + pre-bench gate; the hardware checks a test cannot make are BENCH.md step 8. **Episodes**: `episode_record` samples `joint_states` (observation), the `arm_controller/joint_trajectory` topic (action — read off the wire rather than from the teleop node, so an `arm-pose` session records too) and diff --git a/mote_arm/BENCH.md b/mote_arm/BENCH.md index aacf292..0fa9edb 100644 --- a/mote_arm/BENCH.md +++ b/mote_arm/BENCH.md @@ -375,35 +375,48 @@ way out. ## Step 8 — keyboard teleop, recording and replay -The teleop path has its own guided session, because it needs three terminals -and because three of its checks are observations no script can make (the arm -stopping at a limit, halting on a released key, going limp on panic). - -**Rehearse it headless first** — the same loop runs against the mock follower +**Rehearse it headless first** — the whole loop runs against the mock follower with no hardware at all, and a failure there is a software bug, not a bench one: ``` pixi run arm-teleop-test ``` -Then, on the arm: +Then, on the arm, two terminals: ``` -# terminal A -pixi run arm mirror:=true -# terminal B -pixi run arm-teleop -# terminal C -pixi run arm-bench-teleop +pixi run arm # or `pixi run launch`, if you want the camera +pixi run arm-teleop # '?' prints the keys ``` -Terminal C walks through the safety demonstrations, records an episode while -you teleop it, checks the capture holds a real motion, prints the off-board -export/inspect commands, and replays the episode at quarter speed. It writes -`$MOTE_HOME/episodes/bench/bench-report.txt` — nothing is recorded as passing -that you did not say you saw. +Three of these are observations no test can make — the arm stopping at a limit, +halting on a released key, going limp on panic — which is the whole reason a +human is here. Keep a hand near SPACE throughout. + +1. **It follows.** Hold one joint's key. The arm moves smoothly, not in steps. +2. **It stops at the soft limit.** Keep holding past the limit. It stops there + and goes no further. Check the angle it stopped at is the limit `?` printed: + stopping short of that is the servo's own fence, not the soft limit — see + [the goal-range limits](README.md#the-servos-own-goal-range-limits-which-are-not-the-soft-limits). +3. **Releasing stops it.** Drive, then let go mid-move. It halts within a + fraction of a second, and does not coast on to where it was heading. +4. **Panic drops torque.** Press SPACE. The arm goes limp — back-drivable by + hand — and stays limp while you keep pressing joint keys. +5. **Clearing resumes without a jump.** Press `z`, then drive again. It picks up + from where the arm is, not from where the command had got to. +6. **Step mode.** Press `m`, then tap a joint key: exactly one 0.05 rad + increment per press, and *holding* the key steps once rather than repeatedly. + +Then record, check, and replay: + +``` +pixi run arm-record -- --task "move the arm through a simple motion" --dataset bench +python3 mote_arm/test/teleop_loop/check_capture.py ~/.mote/episodes/bench +pixi run arm-replay -- ~/.mote/episodes/bench --episode 0 # stop teleop first +``` -Full workflow and design: [TELEOP.md](TELEOP.md). +The export is off-board and verifies itself by loading the dataset back through +LeRobot's own API. Full workflow and design: [TELEOP.md](TELEOP.md). --- @@ -450,7 +463,7 @@ Still open: `pixi run robot`, drive a short goal, and teleop the arm at the same time; watch for wheel-odometry glitches that would mean the bus is oversubscribed - [ ] step 8: teleop, record, export/inspect and replay on the arm - (`pixi run arm-bench-teleop`) — verified headless against the mock + (BENCH.md step 8) — verified headless against the mock control stack, but not yet on hardware - [ ] the other five joints jogged and direction-checked (`invert`) - [ ] re-check the gain with a payload on the gripper — the sweep only measures diff --git a/mote_arm/TELEOP.md b/mote_arm/TELEOP.md index 43077ae..25af650 100644 --- a/mote_arm/TELEOP.md +++ b/mote_arm/TELEOP.md @@ -309,4 +309,3 @@ control surface for a remote arm would not be a topic in the first place. | `control.py` | Shared with `arm-pose` and replay: single-point trajectories, and activation as the torque switch. | | `tools/lerobot_export.py` | Capture → LeRobotDataset, off-board (`-e lerobot`). | | `test/teleop_loop/` | The headless end-to-end gate (`arm-teleop-test`). | -| `tools/bench_teleop.sh` | The guided hardware session (see `BENCH.md`). | diff --git a/mote_arm/tools/bench_teleop.sh b/mote_arm/tools/bench_teleop.sh deleted file mode 100755 index 11855c1..0000000 --- a/mote_arm/tools/bench_teleop.sh +++ /dev/null @@ -1,183 +0,0 @@ -#!/usr/bin/env bash -# Guided bench session for keyboard teleop: teleop -> record -> inspect -# -> replay, with the safety behaviours demonstrated on the way. -# -# This is the hardware counterpart of `pixi run arm-teleop-test`, which runs the -# same loop headless against the mock follower. Run that first — this script -# assumes the software already works and is here to check the *arm* does. -# -# Three terminals. The first is the robot, the second is what you drive, and -# the third is this script: -# -# 1. pixi run launch base + camera -# (`pixi run arm` is the same thing without lidar/camera) -# 2. pixi run arm-teleop YOU DRIVE THIS ONE -# 3. pixi run arm-bench-teleop <- this script: asks, records, replays -# -# It writes a report you can paste into the task; nothing is recorded as passing -# that you did not say you saw. -set -euo pipefail - -DATASET="${1:-bench}" -CAPTURE="${MOTE_HOME:-$HOME/.mote}/episodes/$DATASET" -REPORT="$CAPTURE/bench-report.txt" -HERE="$(cd "$(dirname "$0")" && pwd)" - -note() { printf '%s\n' "$*" | tee -a "$REPORT"; } -rule() { printf '\n== %s ==\n' "$*" | tee -a "$REPORT"; } - -# Every answer is typed in THIS terminal, never in the teleop one: there, 'y' -# drives joint 6 and 'z' clears the panic latch, so an answer aimed at the wrong -# window moves the arm instead of being read. Hence the marker on every prompt. -HERE_MARK="[answer HERE]" - -ask() { - # ask "" -> records observed / NOT OBSERVED - local prompt="$1" reply - read -r -p " $HERE_MARK $prompt [y/N] " reply - if [[ "$reply" =~ ^[Yy] ]]; then - note " PASS $prompt" - else - note " FAIL $prompt" - FAILURES=$((FAILURES + 1)) - fi -} - -check() { - # check "" "" - echo - echo " -> in the TELEOP terminal: $1" - ask "$2" -} - -FAILURES=0 -mkdir -p "$CAPTURE" -: >"$REPORT" -note "mote_arm keyboard teleop bench session" -note "date: $(date -Is)" -note "capture: $CAPTURE" - -rule "0. preconditions" -cat <<'EOF' -Before starting, confirm at the arm: - * it is powered, physically supported, and free to move through its band - * `pixi run arm-setup gains show` reports kp=32 (droop, not stall — see README) - * the arm is up: `pixi run launch` (with the camera) or `pixi run arm` - * the TELEOP terminal is running `pixi run arm-teleop` — the one you drive - -This is the last terminal: it asks the questions and records the answers. -EOF -read -r -p " $HERE_MARK ready? [y/N] " ready -[[ "$ready" =~ ^[Yy] ]] || { echo "aborted"; exit 1; } - -rule "1. the arm is reporting" -if timeout 10 ros2 topic echo --once /joint_states >/dev/null 2>&1; then - note " PASS /joint_states is publishing" -else - note " FAIL no /joint_states — is the ARM terminal running?" - exit 1 -fi -NODES="$(ros2 node list 2>/dev/null)" -if grep -q arm_teleop <<<"$NODES"; then - note " PASS arm_teleop is up" -else - note " FAIL no arm_teleop — every check below asks you to drive the arm" - note " from it. Open another terminal and run \`pixi run arm-teleop\`." - exit 1 -fi - -rule "2. teleop, and the three safety behaviours" -cat <<'EOF' -One at a time: do the action in the TELEOP terminal (`pixi run arm-teleop`), -then come back to THIS terminal and answer. Keep a hand on SPACE throughout. - -Do not answer in the teleop terminal — 'y' drives joint 6 there and 'z' clears -the panic latch, so an answer typed into the wrong window moves the arm. -EOF -check "hold one joint's key and watch the arm move" \ - "(a) the arm followed the leader smoothly" -check "keep holding that same key past the joint's soft limit" \ - "(b) it stopped at the limit and went no further" -check "drive again, then release the key mid-move" \ - "(c) releasing the key halted it within a fraction of a second" -check "press SPACE" \ - "(d) PANIC dropped torque and the arm went limp" -check "press z to clear the latch, then drive again" \ - "(e) it resumed following from where it is, with no jump" - -rule "3. record an episode" -echo "Drive a simple motion in the TELEOP terminal while this records." -echo "The ENTER prompts below are read HERE, not there." -echo "Press ENTER to start the recording; 'q' ends the step, so pressing it" -echo "first leaves nothing to check, export or replay." -ros2 run mote_arm episode_record --task "${TASK:-move the arm through a simple motion}" \ - --dataset "$DATASET" --episodes 1 2>&1 | tee -a "$REPORT" - -rule "4. check the capture" -if python3 "$HERE/../test/teleop_loop/check_capture.py" "$CAPTURE" 2>&1 | tee -a "$REPORT"; then - note " PASS capture holds a real motion" - RECORDED=1 -else - note " FAIL capture check" - FAILURES=$((FAILURES + 1)) - RECORDED=0 -fi - -# Steps 5 and 6 export and replay the episode step 3 recorded. With no episode -# they can only ask about work nobody can do, and a FAIL for each would bury -# the one thing that went wrong. -if [ "$RECORDED" -eq 0 ]; then - rule "5-6. export and replay" - note " SKIPPED there is no episode to export or replay" - rule "result" - note "$FAILURES check(s) failed. Record an episode in step 3 and re-run." - note "report: $REPORT" - exit 1 -fi - -rule "5. export and inspect (off-board)" -cat </dev/null | grep -q arm_teleop || break - echo -n "." - sleep 2 -done -echo -if ros2 node list 2>/dev/null | grep -q arm_teleop; then - note " SKIP replay: teleop is still running after 2 minutes" - FAILURES=$((FAILURES + 1)) -else - note " teleop has exited; replaying" - ros2 run mote_arm episode_replay "$CAPTURE" --episode 0 --speed-scale 0.25 2>&1 | tee -a "$REPORT" - ask "the arm retraced the recorded motion" -fi - -rule "result" -if [ "$FAILURES" -eq 0 ]; then - note " PASS — teleop, recording, inspection and replay all verified on hardware" -else - note " $FAILURES check(s) did not pass" -fi -note "" -note "report: $REPORT" -exit "$((FAILURES > 0))" diff --git a/pixi.toml b/pixi.toml index 3509756..fff9440 100644 --- a/pixi.toml +++ b/pixi.toml @@ -167,9 +167,6 @@ arm-replay = "ros2 run mote_arm episode_replay" # The whole teleop -> record -> export -> replay loop against the mock arm; the # pre-bench gate for anything that touches the arm's teleop path. arm-teleop-test = "bash mote_arm/test/teleop_loop/run_teleop_loop.sh" -# The same loop on real hardware, guided: prompts for the safety observations a -# script cannot make, and writes a report (mote_arm/BENCH.md). -arm-bench-teleop = "bash mote_arm/tools/bench_teleop.sh" # Robot-side inference diagnostics (torch-free — run in the default/robot env): # probe the inference machine's health/version, or benchmark round-trip latency. inference-health = "python -u mote_perception/tools/inference_health.py"