diff --git a/CLAUDE.md b/CLAUDE.md index 8880ce8..deae6f1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -23,10 +23,15 @@ 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-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-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 # 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 +pixi run arm-teleop-test # Headless teleop->record->replay loop vs the mock arm 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 +82,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) @@ -687,6 +698,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 @@ -705,16 +727,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 @@ -722,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 + 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-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). @@ -794,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 @@ -806,6 +831,49 @@ 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-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 + 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-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 + 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-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-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 + 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 + `arm.yaml` always binds first and the fence can never be what stops the arm in + 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-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* @@ -819,8 +887,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 @@ -845,11 +927,59 @@ 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 - control stack stopped (`pixi run kill`). `jog` and `arm-pose` do not. + tools (`arm-setup check`, `arm-setup gains`), which still open the bus directly, need the + 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`. -- `arm_gains` (`pixi run arm-gains show|apply|sweep`) — the servos' position-loop +- **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 command's *liveness* is the deadman: a released key, + a closed window and a dropped SSH session all arrive as "no fresh pose", and + 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 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 (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 + workstation: `pixi run arm-teleop-test` drives it headless and is the + 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 + `/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-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 @@ -875,8 +1005,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/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..0fa9edb 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 @@ -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. +`arm-teleop` 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 @@ -182,13 +182,37 @@ 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 + +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. + +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: + +``` +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 @@ -237,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. @@ -272,8 +298,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 @@ -285,15 +312,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); @@ -301,11 +328,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 @@ -325,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.** @@ -335,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 @@ -346,6 +373,51 @@ 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 — keyboard teleop, recording and replay + +**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, two terminals: + +``` +pixi run arm # or `pixi run launch`, if you want the camera +pixi run arm-teleop # '?' prints the keys +``` + +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 +``` + +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). + --- ## Sign-off checklist @@ -362,12 +434,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 @@ -380,16 +452,19 @@ 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 + (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 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..7ec939a 100644 --- a/mote_arm/README.md +++ b/mote_arm/README.md @@ -28,11 +28,17 @@ 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 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 @@ -94,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`. `arm-teleop` 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: @@ -170,11 +176,12 @@ 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-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** | 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 @@ -187,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. @@ -212,18 +219,18 @@ 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 -**`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. @@ -271,7 +278,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. @@ -303,7 +310,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`.) @@ -349,6 +356,91 @@ 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 +``` + +**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-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 +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-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-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 +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 +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-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-setup limits set`. A *narrower* envelope belongs in `arm.yaml`, where +`arm-pose limits` already puts one and where three commands print it; +`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 The base layer captures a map position by driving there and running @@ -365,7 +457,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. @@ -375,16 +467,15 @@ 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 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 @@ -393,8 +484,8 @@ 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 -a mission. `arm-check`, `arm-gains`, `arm-calibrate` and `arm-offsets` open the +`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 it. @@ -403,6 +494,47 @@ 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 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 +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` 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 Nothing moves without an explicit command. The policy did not change when the @@ -422,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 @@ -446,10 +578,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) +## 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. -The camera does not fit when the SO-101 arm is attached. That is an unresolved -mechanical clash, tracked separately — not addressed here. +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 @@ -462,18 +603,18 @@ 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. 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. -`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. @@ -508,7 +649,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: @@ -531,7 +672,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 | @@ -569,12 +710,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 @@ -582,14 +723,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 new file mode 100644 index 0000000..25af650 --- /dev/null +++ b/mote_arm/TELEOP.md @@ -0,0 +1,311 @@ +# 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 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 +what a policy would later be learned from. Teleop without recording is just a +slower jog CLI. + +``` + 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 + +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 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 + +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 + +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 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-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. +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 +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. The control stack + +```bash +pixi run arm # bench: controllers only +pixi run launch # the whole base, when you need the camera +``` + +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 + +```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 commanded pose to the arm +tap SPACE PANIC: torque off, latched z clear it +tap [ ] slower / faster ? help x quit +``` + +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 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 `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 + +```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 *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-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 +> 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`: +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 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.) + +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 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. + +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-teleop # terminal 2 +``` + +`--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 → `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. + +## 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`: 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 | 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 `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 + +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 +`arm_controller` around it. + +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. | +| `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. | +| `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-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`). | diff --git a/mote_arm/mote_arm/arm_calibrate.py b/mote_arm/mote_arm/arm_calibrate.py index 7e75616..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, @@ -67,30 +66,16 @@ homing_offset, limits_from_sweep, pose_impact, + fence_counts, + limits_backup_path, save_calibration, + save_limits_backup, save_offsets_backup, zero_shift, ) 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 @@ -145,17 +130,91 @@ def final(self, rows: list[str]) -> None: print(line) -def _phase_centre(bus, joints, recorders, args) -> dict[str, int]: - """Move each joint's zero to the middle of the range just swept. +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 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: + 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 + return bands + + +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}{'->':>4}{'measured stops':>16}") + for joint in changing: + low, high = bands[joint.name] + want = want_fence.get(joint.name) + if want is None: + continue + print( + f"{joint.name:<16}{f'{low}..{high}':>13}{'->':>4}" + f"{f'{want[0]}..{want[1]}':>16}" + ) + + +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-setup 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: @@ -178,45 +237,66 @@ 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) + 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 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]} - if not stale: - print("\nevery servo is already centred — nothing to write.") + 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"), - ) - print(f"backed up to {backup} (`pixi run arm-offsets restore` undoes this)") + 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-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-setup limits restore`)") 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 @@ -226,7 +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" + 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." ) @@ -384,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" ) @@ -406,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="", @@ -433,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: @@ -457,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) @@ -489,8 +564,12 @@ def _run(bus, cfg, selected, args) -> None: if not usable: raise SystemExit("\nno joint produced a usable sweep — nothing to emit") + # 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: @@ -501,12 +580,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") @@ -517,6 +597,8 @@ def _run(bus, cfg, selected, args) -> None: print(f" {name:<16} {reason}") _save(cfg, calibrated, offsets, recorded) + if args.skip_homing: + _report_fences(usable, bands, calibrated) _migrate_poses(cfg, calibrated) _next_steps() @@ -575,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 40395f1..8203420 100644 --- a/mote_arm/mote_arm/arm_check.py +++ b/mote_arm/mote_arm/arm_check.py @@ -4,32 +4,68 @@ 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.""" return os.path.realpath(port) -def main() -> None: - parser = argparse.ArgumentParser(description="SO-101 arm bus check") - parser.add_argument("--robot-yaml", default="", help="override robot.yaml path") +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 refuses goals outside its own " + "band, silently, so the joint stops there whatever robot.yaml and " + "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 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", @@ -37,54 +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}" ) - 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}" - ) - finally: - bus.close() + limits.append((joint, bus.read_angle_limits(joint.id))) + _report_angle_limits(limits) if missing: print( @@ -97,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 new file mode 100644 index 0000000..155181c --- /dev/null +++ b/mote_arm/mote_arm/arm_limits.py @@ -0,0 +1,181 @@ +"""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-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``, +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 + +from datetime import datetime, timezone + +from mote_arm.bus import COUNTS_PER_TURN +from mote_arm.calibrate import ( + limits_backup_path, + load_limits_backup, + save_limits_backup, +) + +FULL_RANGE = (0, COUNTS_PER_TURN - 1) + + +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-setup 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-setup 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 add_subparser(sub) -> None: + parser = sub.add_parser( + "limits", help="the servos' goal-range fence (EEPROM registers 9 and 11)" + ) + 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 = 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) + inner.add_parser("restore", help="write the as-found bands back").set_defaults( + func=_cmd_restore + ) + + +__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 f149978..39a7349 100644 --- a/mote_arm/mote_arm/arm_pose.py +++ b/mote_arm/mote_arm/arm_pose.py @@ -9,13 +9,32 @@ 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 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, +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`` +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 +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,7 +57,9 @@ 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 class PoseClient(Node): @@ -84,11 +105,60 @@ def _require_states(node: PoseClient) -> dict[str, float]: 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-setup 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-setup calibrate` re-measures them." + ) def _cmd_list(node: PoseClient, args) -> None: @@ -184,18 +254,6 @@ 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: - 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." - ) - - 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) @@ -233,27 +291,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 " @@ -304,13 +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( - "--max-travel", - type=float, - default=0.35, - help="refuse if any joint would move more than this many rad (default 0.35)", - ) p_go.add_argument( "--speed", type=float, 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/arm_teleop.py b/mote_arm/mote_arm/arm_teleop.py new file mode 100644 index 0000000..3de6cbc --- /dev/null +++ b/mote_arm/mote_arm/arm_teleop.py @@ -0,0 +1,568 @@ +"""Keyboard teleoperation of the SO-101 arm. + +One process, one node. The keyboard moves a commanded pose; every safety rule +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. + + hold q/a w/s e/d r/f t/g y/h move joint 1..6 up/down + 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 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 + +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 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, 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")] + +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. +LIVE_LINE_PERIOD = 0.15 + + +class ArmTeleop(Node): + """The keyboard, the safety rules and the arm, in one node.""" + + 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", 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) + + 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._estop_requested = False + + self.mirror = PoseFollower( + self.cfg.joints, + FollowLimits( + 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): + 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 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: + 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 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 + 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 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 — `PoseFollower` clamps, rate-limits and may be latched off. + """ + if self.diagnostics is not None: + 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. + + 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. +# 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: 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 + 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" + 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: + 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) > FollowLimits.max_lag: + line += " NOT FOLLOWING" + parts.append(line) + return " " + " ".join(parts) + + +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): + # 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.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(" m hold/step mode p all joint positions ? this help x quit") + + +def _status(node: ArmTeleop, 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: 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(): + _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: + _clear_live() + return + if key in node.keys: + name, direction = node.keys[key] + 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) + 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("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 == "?": + # 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) + 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 + 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 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 + + time.sleep(period) + + +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 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 + 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.offer() + time.sleep(period) + _out("demo finished") + + +def main() -> None: + parser = argparse.ArgumentParser(description="Keyboard teleop for the SO-101") + parser.add_argument( + "--speed", + type=float, + 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 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, + default=None, + metavar="SECONDS", + help="sweep the first joint for N seconds without a terminal (tests, checks)", + ) + args = cli.parse(parser) + + rclpy.init() + 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: + _demo(node, args.demo) + else: + _interactive(node, args.step) + except KeyboardInterrupt: + pass + finally: + # 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: 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( + "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: + tty.setcbreak(sys.stdin.fileno()) + _drive(node, step_size) + finally: + termios.tcsetattr(sys.stdin, termios.TCSADRAIN, settings) + + +if __name__ == "__main__": + main() diff --git a/mote_arm/mote_arm/bus.py b/mote_arm/mote_arm/bus.py index 4df4747..4cd2924 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 @@ -122,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.""" @@ -346,6 +378,56 @@ 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 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..b2676dd 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 @@ -508,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" @@ -588,3 +621,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/mote_arm/config.py b/mote_arm/mote_arm/config.py index 011bd91..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. @@ -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-setup 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 @@ -70,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. """ @@ -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/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/diagnostics.py b/mote_arm/mote_arm/diagnostics.py new file mode 100644 index 0000000..7d4d29a --- /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_command(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/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..9be3efe --- /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 +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. + +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..8bf5da8 --- /dev/null +++ b/mote_arm/mote_arm/episode_replay.py @@ -0,0 +1,274 @@ +"""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 teleop 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 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) + 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/jog.py b/mote_arm/mote_arm/jog.py deleted file mode 100644 index 27abd9d..0000000 --- a/mote_arm/mote_arm/jog.py +++ /dev/null @@ -1,206 +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.holding 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 new file mode 100644 index 0000000..c401303 --- /dev/null +++ b/mote_arm/mote_arm/mock_arm.py @@ -0,0 +1,243 @@ +"""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 `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 +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.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, + LIST_SERVICE, + 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) + # 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) + + 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_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 + 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 = 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: + spinner.join() + except KeyboardInterrupt: + pass + finally: + cli.shutdown(node, spinner) + + +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..6772a28 --- /dev/null +++ b/mote_arm/mote_arm/teleop.py @@ -0,0 +1,209 @@ +"""The follow rule, with no ROS and no hardware attached. + +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 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 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. + +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 FollowLimits: + """How fast the arm may chase the commanded pose, and when it stops trying.""" + + # 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 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 + # 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 follower is doing, for logging and for tests to assert on. +TRACKING = "tracking" +HOLDING = "holding" # deadman: no fresh commanded pose +ESTOPPED = "estopped" +WAITING = "waiting" # no measured state yet, so nothing is safe to command + + +class PoseFollower: + """Turns commanded poses into rate-limited, clamped goals for the arm.""" + + def __init__( + self, + joints: Sequence[JointSpec], + limits: FollowLimits | None = None, + ): + self._joints = {j.name: j for j in joints} + self.limits = limits or FollowLimits() + self._measured: dict[str, float] = {} + 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 — + # 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 + # 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 + 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_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 arm 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._command_stamp is None + or (now - self._command_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] = {} + stalled: list[str] = [] + for name, target in self._command.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 + + +def sync_pose( + measured: Mapping[str, float], joints: Sequence[JointSpec] +) -> dict[str, float]: + """The commanded pose when it re-syncs to the arm: measured, then clamped. + + 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/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..486b205 100644 --- a/mote_arm/setup.py +++ b/mote_arm/setup.py @@ -20,12 +20,12 @@ license="Apache-2.0", 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_setup = mote_arm.arm_setup:main", "arm_pose = mote_arm.arm_pose:main", - "arm_gains = mote_arm.arm_gains: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/check_capture.py b/mote_arm/test/teleop_loop/check_capture.py new file mode 100755 index 0000000..048c816 --- /dev/null +++ b/mote_arm/test/teleop_loop/check_capture.py @@ -0,0 +1,101 @@ +#!/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]) + # 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: + 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: + # 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(frames) - len(present)}/{len(frames)} " + "frames have no image" + ) + 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) + 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..0683428 --- /dev/null +++ b/mote_arm/test/teleop_loop/run_teleop_loop.sh @@ -0,0 +1,130 @@ +#!/usr/bin/env bash +# The whole teleop loop against a follower that isn't there. +# +# 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 +# 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 + +# `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 name in "${NAMES[@]:-}"; do + [ -n "$name" ] && reap "$name" + done +} +trap cleanup EXIT + +fail() { + echo "FAIL: $*" >&2 + echo "--- logs in $LOGS ---" >&2 + tail -n 20 "$LOGS"/*.log >&2 || true + exit 1 +} + +background() { + local name="$1" + shift + 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 + reap "$name" + 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 +sleep 4 + +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 -- \ + --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 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" +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_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_arm_pose_reach.py b/mote_arm/test/test_arm_pose_reach.py new file mode 100644 index 0000000..4a54e60 --- /dev/null +++ b/mote_arm/test/test_arm_pose_reach.py @@ -0,0 +1,53 @@ +"""A taught pose the arm cannot be sent to. + +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. +""" + +import pytest + +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) + + +# --- 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 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 new file mode 100644 index 0000000..a8abf88 --- /dev/null +++ b/mote_arm/test/test_calibrate_fences.py @@ -0,0 +1,167 @@ +"""`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-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 +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; 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, arm_limits +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_limits.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 + + +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 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 + ) + + +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) + + +# --- what --skip-homing reports instead of writing --------------------------- + + +def joints(): + return [spec(), spec(name="wrist_roll", servo_id=5)] + + +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_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_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-setup 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)) + 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..25ff329 --- /dev/null +++ b/mote_arm/test/test_calibrate_phase2.py @@ -0,0 +1,193 @@ +"""Phase 2 end to end against a fake bus: one confirmation, both EEPROM writes. + +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, a gap where a joint has no fence — fails here rather than on +hardware. +""" + +from mote_arm import arm_calibrate, arm_limits +from mote_arm.calibrate import ( + Sweep, + calibrate_centred, + load_limits_backup, + load_offsets_backup, +) +from mote_arm.config import JointSpec + +FULL = arm_limits.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_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) + 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) + assert load_offsets_backup() == {"shoulder_lift": -1103, "elbow_flex": 1551} + assert load_limits_backup() == { + "shoulder_lift": (1478, 3859), + "elbow_flex": (716, 2941), + } + + +def test_the_arm_ends_fenced_at_its_measured_stops(tmp_path, monkeypatch): + bus = fresh_bus() + run(bus, tmp_path, monkeypatch) + 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 +# 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 == {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"] == [] diff --git a/mote_arm/test/test_cli.py b/mote_arm/test/test_cli.py index bf0543f..b5cbbaa 100644 --- a/mote_arm/test/test_cli.py +++ b/mote_arm/test/test_cli.py @@ -29,24 +29,29 @@ 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(): 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: 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_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 new file mode 100644 index 0000000..4f9a128 --- /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(): + """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 == [ + 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 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_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_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..47f7b65 --- /dev/null +++ b/mote_arm/test/test_teleop.py @@ -0,0 +1,212 @@ +"""The follow rule: clamping, rate limiting, the deadman, and the panic latch. + +Every safety property of keyboard teleop is decided in ``PoseFollower``, +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, + PoseFollower, + FollowLimits, + 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 = FollowLimits(max_velocity=1.0, deadman_timeout=0.4) +DT = 0.05 + + +def mirror(**kwargs) -> PoseFollower: + return PoseFollower(JOINTS, FollowLimits(**{**LIMITS.__dict__, **kwargs})) + + +def drive( + 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_command(leader, now) + goal = m.update(now, DT) + return goal + + +def test_nothing_is_commanded_before_the_arm_reports(): + m = mirror() + m.on_command({"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_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) + + +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): + FollowLimits(max_velocity=0.0) + with pytest.raises(ValueError): + FollowLimits(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_command({"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_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}) + 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_command({"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_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/test/test_teleop_node.py b/mote_arm/test/test_teleop_node.py new file mode 100644 index 0000000..1622e09 --- /dev/null +++ b/mote_arm/test/test_teleop_node.py @@ -0,0 +1,224 @@ +"""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 (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 +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. + +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. +""" + +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 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( + { + "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 Stack: + """The teleop node, its follower, and the two loops that drive them.""" + + def __init__(self, mock, teleop, executor): + self.mock = mock + self.teleop = teleop + self._executor = executor + self.pose: dict[str, float] | None = None + + def run(self, seconds: float) -> None: + """Advance both loops, as `main` runs them on two threads.""" + deadline = time.monotonic() + seconds + while time.monotonic() < deadline: + 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] + + +@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) + teleop = teleop_mod.ArmTeleop(speed=0.25, key_timeout=0.35) + + executor = SingleThreadedExecutor() + for node in (mock, teleop): + executor.add_node(node) + spinner = threading.Thread(target=executor.spin, daemon=True) + spinner.start() + + 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, teleop): + node.destroy_node() + rclpy.shutdown() + + +def test_the_arm_follows_the_keyboard(stack): + start = stack.at("elbow_flex") + stack.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.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 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.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.pose = {"elbow_flex": 1.0} + stack.run(0.6) + stack.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.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.pose = {"elbow_flex": 1.0} + stack.run(0.4) + stack.panic(True) + stack.run(0.4) + # Torque is controller activation, so dropping it means deactivating. + assert stack.mock.holding is False + + # 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) + assert stack.at("elbow_flex") == pytest.approx(stopped, abs=1e-6) + + +def test_clearing_panic_lets_the_arm_move_again(stack): + stack.pose = {"elbow_flex": 1.0} + stack.run(0.3) + stack.panic(True) + stack.run(0.3) + stopped = stack.at("elbow_flex") + + 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_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..36356b3 100644 --- a/mote_bringup/launch/arm_launch.py +++ b/mote_bringup/launch/arm_launch.py @@ -11,8 +11,12 @@ `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 +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 diff --git a/mote_bringup/mote_bringup/launch_utils.py b/mote_bringup/mote_bringup/launch_utils.py index 56e96dd..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",) @@ -57,7 +57,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.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..fff9440 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 @@ -147,21 +143,30 @@ 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" -# 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" -# 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" +# 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. +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" # 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 +450,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 +500,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