From 7fd2636f3323fc92c12e73d3b4fb04531d6427af Mon Sep 17 00:00:00 2001 From: Xinyi YUAN Date: Tue, 22 Sep 2026 14:43:29 +0900 Subject: [PATCH 01/10] feat(bench): benchmark seven more atomic skills Rebased onto the measurement standard. Adds open_door, press, slide, twist, axis_align, pour and hand_over benchmarks, the shared contact-skill measurement layer they run on, and their entries in the suite dispatcher. Dropped from the earlier revision of this branch: - Four commits whose content reached main through #620. Their five benchmark files are now byte-identical to main, and replaying them would have removed the affordance_sampling support that landed after the branch point. - Its own copy of BENCHMARK_STANDARD.md, superseded by the one on the base branch. - The press and slide tutorial strategy fix, which is already on main; both tutorials request strategy="ik_interp" there today. Co-Authored-By: Claude Opus 5 --- .../atomic_action/BENCHMARK_REPORT.md | 354 +++++++++++ .../atomic_action/axis_align_benchmark.py | 524 ++++++++++++++++ scripts/benchmark/atomic_action/common.py | 547 +++++++++++++++++ .../atomic_action/hand_over_benchmark.py | 573 +++++++++++++++++ .../atomic_action/open_door_benchmark.py | 538 ++++++++++++++++ .../benchmark/atomic_action/pour_benchmark.py | 577 ++++++++++++++++++ .../atomic_action/press_benchmark.py | 508 +++++++++++++++ .../benchmark/atomic_action/run_benchmark.py | 7 + .../atomic_action/slide_benchmark.py | 401 ++++++++++++ .../atomic_action/twist_benchmark.py | 372 +++++++++++ 10 files changed, 4401 insertions(+) create mode 100644 scripts/benchmark/atomic_action/BENCHMARK_REPORT.md create mode 100644 scripts/benchmark/atomic_action/axis_align_benchmark.py create mode 100644 scripts/benchmark/atomic_action/hand_over_benchmark.py create mode 100644 scripts/benchmark/atomic_action/open_door_benchmark.py create mode 100644 scripts/benchmark/atomic_action/pour_benchmark.py create mode 100644 scripts/benchmark/atomic_action/press_benchmark.py create mode 100644 scripts/benchmark/atomic_action/slide_benchmark.py create mode 100644 scripts/benchmark/atomic_action/twist_benchmark.py diff --git a/scripts/benchmark/atomic_action/BENCHMARK_REPORT.md b/scripts/benchmark/atomic_action/BENCHMARK_REPORT.md new file mode 100644 index 000000000..ad48e0fce --- /dev/null +++ b/scripts/benchmark/atomic_action/BENCHMARK_REPORT.md @@ -0,0 +1,354 @@ +# Atomic-Action Benchmark: Results and Technical Report + +Branch `xinyi/atomic-bench-all` in `/tmp/bench_night`, based on `main` +(`7bc7271c`). Nothing is committed; the working tree holds all changes. + +Environment: `embodichain311` (Python 3.11), `--device cpu`, DexSim default +backend, physics timestep 10 ms, single environment. Reports in +`outputs/benchmarks/`. Metric definitions and threshold justifications live in +[`BENCHMARK_STANDARD.md`](BENCHMARK_STANDARD.md). + +--- + +## 1. One-page summary + +**12 atomic-action benchmarks now run end to end.** Five existed but could not +run on `main`; seven are new. + +| Skill | Status | plan | valid | exec | task | +|---|---|---|---|---|---| +| `move_joints` | restored | ok | ok | ok | ok | +| `move_end_effector` | restored | ok | ok | ok | ok | +| `pick_up` | restored | ok | ok | ok | ok | +| `place` | restored | ok | ok | ok | ok | +| `move_held_object` | restored | ok | ok | ok | ok | +| `open_door` | **new** | 3/3 | 3/3 | 3/3 | **3/3** | +| `press` | **new** | 3/3 | 3/3 | 3/3 | **3/3** | +| `slide` | **new** | 3/3 | 3/3 | 3/3 | **3/3** | +| `axis_align` | **new** | 2/2 | 2/2 | 2/2 | **2/2** | +| `pour` | **new** | 3/3 | 3/3 | 3/3 | **3/3** | +| `hand_over` | **new** | 2/2 | 2/2 | 2/2 | **2/2** | +| `twist` | **new** | 3/3 | 3/3 | 3/3 | **0/3** | + +**Twist is the only skill that does not reach `task_success`, and the cause is +not Twist's planning.** Its planned end-effector rotation about the knob axis is +exactly the commanded angle; the simulated grasp cranks the freely-spinning knob +past it. Details in section 4. + +**Not completed:** `push_object`, `coordinated_pickment`, +`coordinated_placement`. Section 6. + +**Needs your decision:** seven threshold/scope questions, section 7. + +--- + +## 2. What was broken, and what fixing it required + +### 2.1 Restoring the five shipped benchmarks + +PR #620's fixes were re-applied by cherry-picking `42728c12` with `-n` (staged, +not committed): missing tutorial exports, missing planning context, missing +simulator teardown, a stale `get_hand_open_close_qpos` signature, and a +too-tight `move_end_effector` tolerance. + +That alone was not enough. **Additional fix required by the current DexSim:** +the extracted `create_robot` helpers never called `sim.prepare()`, so +`robot.body_data` was `None` and every benchmark died at the first +`robot.get_qpos()`. + +- *Before*: `AttributeError: 'NoneType' object has no attribute 'qpos'` in + `articulation.py:1619`, for all five. +- *Fix*: `sim.prepare()` inside each tutorial `create_robot`, and inside + `common.create_benchmark_object` after `add_rigid_object` (adding a body + changes the spawn topology). +- *After*: all five run and write reports. `move_joints` 123 ms, success, clean + exit. + +### 2.2 The replay-rate defect (affects every contact skill) + +This was the most consequential finding of the night. + +`Robot.set_qpos` writes **drive targets**, not teleported state. The benchmark +replay advanced one waypoint per 4 physics steps with no check that the arm had +actually got there. Sweeping the rate on OpenDoor: + +| Steps/waypoint | Max arm tracking error (rad) | Hinge error (rad) | +|---|---|---| +| 1 (planner's own dt) | 1.3844 | 0.2273 | +| 4 (original) | 0.4390 | 0.0689 | +| 16 (**chosen**) | 0.0532 | 0.0306 | +| 32 | 0.0230 | 0.0245 | + +The measured "task" error was largely the controller failing to follow the plan. +I first tried replaying at the planner's own `dt`, which is *worse* — it made +OpenDoor and Slide fail outright. + +**Fix**: `DEFAULT_REPLAY_STEPS_PER_WAYPOINT = 16`, plus a new +`execution_success` gate on `max_tracking_error_rad <= 0.10`, so a case is never +credited or blamed for motion the robot did not perform. + +*Evidence, Slide 180 mm pull:* 0.0525 m error at 1 step → 0.0098 m at 4 → +**0.0006 m at 16**. + +### 2.3 Measurement-point defects (my benchmark code, found by disbelieving green/red) + +Three skills were initially mis-measured. In each case the skill was correct and +the benchmark was wrong. + +**`open_door`** — first run reported `task_success=False`, hinge error 0.380 rad. +Instrumenting each segment boundary showed the hinge reached 0.979 rad at the +end of the `open` segment and then **settled back to 0.667 rad** after the hand +released and the arm retracted: the tutorial microwave's hinge is undriven +(`drive_type="none"`) and swings freely. Scoring moved to the end of the `open` +segment. Settled value retained as a diagnostic only. + +**`pour`** — reported `task_success=False` with ~0.014 rad achieved for a +1.571 rad command. The planned `pour` segment showed +`max_joint_change_over_pour_segment = 0.0000 rad`. Reading +`primitives/pour.py` explained it: Pour plans **two** end-effector targets in +one segment — rotate to the poured pose, then *return to the pose it started +from*. The end of the segment is zero rotation by construction. Scoring moved to +the peak signed rotation. Error dropped to 0.002–0.006 rad. + +**`pour` (second defect, mine)** — PickUp refused to plan in the Pour scene +(`plan failure [pick_up]: planning failed`). Cause: I passed +`strategy="motion_gen"` on the PickUp invocation. Removing it (matching the +working `pickup_benchmark`) made it plan. See 2.4 for the related tutorial bug. + +**`axis_align`** — `KeyError: "axis_align_object_pose references unknown scene +entity 'cube'"`. AxisAlign resolves the object pose from the scene by entity id, +so the engine must be built with +`create_simulation_atomic_action_engine(scene_entities=(obj,))`, not a bare +`AtomicActionEngine`. Fixed; both cases now pass from a 90-degree misalignment. + +### 2.4 A pre-existing bug found in the Pour tutorial + +Running `scripts/tutorials/atomic_action/pour.py` **on this branch, unmodified**, +crashes: + +``` +ValueError: Quantity sampling requires at least one sample per retained +waypoint; received 108, requires at least 277. +``` + +`PICK_MOTION_SAMPLE_COUNT = PICK_SAMPLE_INTERVAL - HAND_INTERP_STEPS` = 108, but +the retained waypoint count is 277. The tutorial's `_create_pick_motion_policy` +forces `strategy="motion_gen"` with an under-sized `sample_interval`. + +I did **not** change the tutorial — the benchmark sidesteps it by leaving the +strategy at its default, and the tutorial fix is a separate decision (raise the +sample count, or stop forcing `motion_gen`). Flagging it because it means Pour's +tutorial is currently unusable. + +--- + +## 3. Results + +All numbers from the final verification run after `black` formatting. Planning +time excludes a discarded warm-up compile. + +### 3.1 Success ladder and task metrics + +| Skill | Case | plan | valid | exec | task | task metric (measured vs rule) | +|---|---|---|---|---|---|---| +| `open_door` | open_30 | ok | ok | ok | **ok** | hinge err 0.0318 rad ≤ 0.15 | +| | open_60 | ok | ok | ok | **ok** | hinge err 0.0332 rad ≤ 0.15 | +| | open_90 | ok | ok | ok | **ok** | hinge err 0.0415 rad ≤ 0.15 | +| `press` | press_10mm | ok | ok | ok | **ok** | peak 6.00 mm ≥ 4.80 mm (80 % of stroke) | +| | press_20mm | ok | ok | ok | **ok** | peak 6.00 mm ≥ 4.80 mm | +| | press_30mm | ok | ok | ok | **ok** | peak 6.00 mm ≥ 4.80 mm | +| `slide` | pull_100mm | ok | ok | ok | **ok** | travel 0.0998 m, err 0.0002 m ≤ 0.03 | +| | pull_180mm | ok | ok | ok | **ok** | travel 0.1794 m, err 0.0006 m ≤ 0.03 | +| | pull_250mm | ok | ok | ok | **ok** | travel 0.2444 m, err 0.0056 m ≤ 0.03 | +| `axis_align` | upright | ok | ok | ok | **ok** | 1.5709 → 0.0480 rad ≤ 0.15 | +| | horizontal | ok | ok | ok | **ok** | 1.5708 → 0.0412 rad ≤ 0.15 | +| `pour` | pour_30 | ok | ok | ok | **ok** | peak 0.5217 rad, err 0.0019 ≤ 0.15 | +| | pour_45 | ok | ok | ok | **ok** | peak 0.7796 rad, err 0.0058 ≤ 0.15 | +| | pour_90 | ok | ok | ok | **ok** | peak 1.5649 rad, err 0.0059 ≤ 0.15 | +| `hand_over` | vertical_can | ok | ok | ok | **ok** | delivery 0.1123 m ≤ 0.12; min z 0.531 m, not dropped | +| | horizontal_pencil | ok | ok | ok | **ok** | delivery 0.0678 m ≤ 0.12; min z 0.532 m, not dropped | +| `twist` | twist_ccw_30 | ok | ok | ok | **FAIL** | knob 2.0286 rad vs 0.5236 commanded, err 1.5050 | +| | twist_ccw_45 | ok | ok | ok | **FAIL** | knob 2.4024 rad vs 0.7854, err 1.6170 | +| | twist_ccw_90 | ok | ok | ok | **FAIL** | knob 3.1416 rad vs 1.5708, err 1.5708 | + +Restored benchmarks (smoke profile): `move_joints` success, joint err 0.000000; +`move_end_effector` success, err 0.0000; `pick_up` success, object lift +0.158 m, XY drift 0.004 m; `place` success; `move_held_object` success. + +### 3.2 Cost + +| Skill | Planning time (ms) | Waypoints | CPU delta (MB) | Peak GPU (MB) | +|---|---|---|---|---| +| `open_door` | 270–287 | 120 | 0.0–7.6 | 29.1 | +| `press` | 316–321 | 140 | 0.0 | 0.0 | +| `slide` | 315–325 | 140 | 0.0–7.7 | 45.7 | +| `twist` | 229–237 | 140 | 0.0 | 0.0 | +| `axis_align` | 321–332 | 180 | 0.0–8.4 | 24.5 | +| `pour` (pick+pour) | 278–284 | 200 | 0.8–6.8 | 42.8 | +| `hand_over` | 665–685 | 220 | — | — | +| `move_joints` | 123 | — | — | — | +| `move_end_effector` | 69 | — | — | — | +| `pick_up` | 579 | 120 | — | — | + +Run on CPU, so GPU deltas are Torch-side only. `press` and `twist` allocate no +GPU memory because they sample no grasps. The CPU delta of the first case in +each sweep is larger than subsequent ones (allocator growth) even after warm-up; +this is visible in the per-case tables. + +--- + +## 4. Twist: root-cause analysis + +`twist` is the one skill whose `task_success` fails. The evidence says the +failure is in the scene's contact physics, not in Twist. + +**What was ruled out:** + +1. *Replay rate.* Arm tracking error at the benchmark rate is **0.026 rad** — + the arm follows the plan almost exactly. Over-rotation persists at every rate + from 2 to 32 steps per waypoint. +2. *Grasp not closing.* The hand is commanded to 0.036 and stalls at 0.0136, + i.e. it is physically blocked by the 5 cm knob. Contact is real. +3. *Planning.* Measured directly: the resolved twist axis is + `(-1, 0, 0)` in world, and the **planned end-effector rotation about that + axis over the `twist` segment is 0.7854 rad for a 0.7854 rad command, with + the correct sign.** Twist plans exactly what it was asked to. + +**What the instrumentation shows instead:** per-segment knob angle for a +0.7854 rad command — + +``` +end_of_close knob=+0.0108 +end_of_twist knob=+1.8736 <- 2.4x commanded, opposite sign +end_of_open knob=+1.8524 (hand has opened) +end_of_retract knob=+2.3749 <- still being dragged after release +after_hold knob=+2.3749 drift=0.0000 +``` + +The knob keeps being driven a further **0.52 rad during `retract`, after the +hand opened**, and then stops dead (zero drift). It is not spinning on +momentum — it is being cranked by contact with the withdrawing fingers. + +**Mechanism:** the gripper wedges against the knob rather than holding it +rigidly, and the knob joint has `stiffness=1e-3`, `damping=1e2`, +`max_effort=1e-2` — essentially no restoring torque — so any tangential contact +force turns it freely past the commanded angle. + +**I did not "fix" this by loosening the tolerance or by stiffening the knob's +joint drive.** Either would manufacture a pass. The benchmark reports +`task_goal_miss` with the measured value, and section 7 asks how you want Twist +gated. + +--- + +## 5. Changes made + +Working tree on `xinyi/atomic-bench-all`, uncommitted. + +**New benchmarks** (`scripts/benchmark/atomic_action/`): `open_door_benchmark.py`, +`press_benchmark.py`, `slide_benchmark.py`, `twist_benchmark.py`, +`axis_align_benchmark.py`, `pour_benchmark.py`, `hand_over_benchmark.py`. All +registered in +`run_benchmark.ACTION_MODULES`. + +**`common.py`** — new shared infrastructure: +`SuccessLadder`, `FAILURE_TAXONOMY`, `LADDER_STAGES`, `check_motion_valid`, +`JointDisplacementTrace`, `replay_and_track_scalar` (the single replay +primitive), `replay_and_track_joint`, `run_articulated_contact_case`, +`waypoint_step_counts`, `warmup_planning`, `build_ladder_leaderboard`, +`DEFAULT_REPLAY_STEPS_PER_WAYPOINT`, `REPLAY_TRACKING_TOLERANCE_RAD`. +Plus the `sim.prepare()` fix in `create_benchmark_object`. + +**Tutorials** — cherry-picked #620 exports, plus `sim.prepare()` in each +extracted `create_robot`. No tutorial behaviour was otherwise changed; in +particular the Pour tutorial's bug (2.4) is reported, not patched. + +**Formatting** — `black==26.3.1` clean across +`scripts/benchmark/atomic_action/` and `scripts/tutorials/atomic_action/`. + +All 12 modules import and expose `run_all_benchmarks` / `add_benchmark_args`. + +--- + +## 6. Not completed + +| Skill | Status | Reason | +|---|---|---| +| `push_object` | not built | No tutorial. The only reference usage is `tests/sim/atomic_actions/test_actions.py`, which drives it with a **mocked** motion generator and synthetic `SceneSnapshot` poses — no physical scene exists to reuse, so a benchmark needs a scene designed from scratch (object, support surface, contact offsets). Deferred after the priority-2 skills. | +| `coordinated_pickment` | not built | Dual-arm, `left`/`right` binding. Not reached. | +| `coordinated_placement` | not built | Dual-arm, and the most involved: requires two sequential PickUps, then re-deriving held state from actual measurements (`compute_actual_held_state`) before planning placement. Not reached. | + +The API facts needed for all four were gathered and are summarized in this +session; none is blocked by a missing dependency. **No skill was blocked by +cuRobo** — every benchmark here uses the TOPPRA/trapezoidal path, so the +"needs cuRobo, cannot verify locally" case did not arise. + +--- + +## 7. Decisions needed from you + +1. **How should `twist` be gated?** Options: (a) keep the knob-joint rule and + accept Twist failing on this asset; (b) gate on the executed end-effector + rotation about the twist axis, which is currently exact; (c) give the + benchmark scene a stiffer knob drive than the tutorial's. *Suggested: (b), + keeping the knob joint as a reported diagnostic. (c) is tuning a scene to make + a test pass and I'd advise against it without an independent reason.* + **Status: undecided.** + +2. **Is `press`'s gate meaningful?** Commanding 3 mm still drove the button to + 5.66 mm of its 6 mm stroke, so the 80 %-of-stroke rule passes regardless of + commanded travel. *Suggested: keep it as an explicit liveness check ("the + button was pressed") and stop treating commanded depth as a controlled + variable on this asset.* **Status: undecided.** + +3. **Tighten `slide`'s tolerance?** 0.03 m is ~30x the measured error (max + 0.0056 m). *Suggested: 0.01 m, which still leaves ~2x margin on the worst + measured case.* **Status: undecided.** + +4. **Should the five original benchmarks adopt the calibrated replay?** They + still use 4 steps/waypoint via + `replay_trajectory_for_physical_validation`, with no tracking check. They + pass, but carry the same systematic bias section 2.2 quantifies. I left their + behaviour unchanged deliberately. *Suggested: align them, in a separate + change.* **Status: undecided.** + +5. **`open_door` at larger angles.** Error grows with commanded angle + (0.0318 / 0.0332 / 0.0415 rad at 30 / 60 / 90 degrees). A 120-degree case was + not tested and could approach the 0.15 rad tolerance. Worth adding before + trusting the tolerance at the extremes. + +6. **Repeat count.** Everything is `--repeat 1`; these are point estimates. The + design document asks for confidence intervals, which needs repeats and a + seeding policy. + +7. **`hand_over`'s delivery tolerance.** 0.12 m was borrowed from the existing + MoveHeldObject tolerance. The vertical-can case lands at 0.1123 m, i.e. only + 7 % inside the gate, so it would flip to a failure under small perturbation. + Either the tolerance is too tight for HandOver, or the delivery is genuinely + marginal and worth investigating. *Suggested: investigate before loosening.* + **Status: undecided.** + +Also awaiting your call: whether to fix the Pour tutorial bug (2.4) here or in a +separate change. + +--- + +## 8. Reproducing + +``` +cd /tmp/bench_night +export PYTHONPATH=/tmp/bench_night +PY=/home/lqin/miniconda3/envs/embodichain311/bin/python + +$PY -m scripts.benchmark.atomic_action.open_door_benchmark --profile coverage --device cpu --n_sample 2000 +$PY -m scripts.benchmark.atomic_action.slide_benchmark --profile coverage --device cpu --n_sample 2000 +$PY -m scripts.benchmark.atomic_action.axis_align_benchmark --profile coverage --device cpu --n_sample 2000 +$PY -m scripts.benchmark.atomic_action.pour_benchmark --profile coverage --device cpu --n_sample 2000 +$PY -m scripts.benchmark.atomic_action.press_benchmark --profile coverage --device cpu +$PY -m scripts.benchmark.atomic_action.twist_benchmark --profile coverage --device cpu +$PY -m scripts.benchmark.atomic_action.hand_over_benchmark --profile coverage --device cpu --n_sample 2000 +``` + +`press` and `twist` sample no grasps and do not accept `--n_sample`. A DexSim +exit code of 139 on teardown is the known-harmless segfault; judge each run by +its printed output and the written report. diff --git a/scripts/benchmark/atomic_action/axis_align_benchmark.py b/scripts/benchmark/atomic_action/axis_align_benchmark.py new file mode 100644 index 000000000..8e6b0f9a7 --- /dev/null +++ b/scripts/benchmark/atomic_action/axis_align_benchmark.py @@ -0,0 +1,524 @@ +# ---------------------------------------------------------------------------- +# Copyright (c) 2021-2026 DexForce Technology Co., Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ---------------------------------------------------------------------------- + +"""Benchmark AxisAlign atomic action on the tutorial cube. + +Reports the four-level success ladder (planning_success, motion_valid, +execution_success, task_success). Task success requires the angle between the +object's internal axis and the commanded target axis to fall within tolerance +at the end of the commanded alignment segment. +Run: embodichain benchmark atomic-action --action axis_align +""" + +from __future__ import annotations + +import argparse +import math +from dataclasses import dataclass +from pathlib import Path + +from scripts.benchmark.atomic_action.common import ( + CPU_MEMORY_BACKEND, + DEFAULT_REPLAY_STEPS_PER_WAYPOINT, + REPLAY_TRACKING_TOLERANCE_RAD, + SuccessLadder, + add_common_benchmark_args, + add_grasp_benchmark_args, + build_ladder_leaderboard, + build_video_output_path, + check_motion_valid, + ensure_repo_root, + ensure_torch, + format_float, + replay_and_track_scalar, + replay_trajectory_with_recording, + reset_rigid_object, + reset_robot, + resolve_profile, + should_record_case, + summarize_video_recording, + timed_call, + warmup_planning, + write_markdown_report, +) + +INTERNAL_AXIS = (1.0, 0.0, 0.0) +ALIGN_SAMPLE_INTERVAL = 180 +HAND_INTERP_STEPS = 12 +PRE_GRASP_DISTANCE = 0.15 +LIFT_HEIGHT = 0.16 +REPLAY_HOLD_STEPS = 60 +# Angle between the object's internal axis and the commanded target axis. +# 0.15 rad (8.6 deg) matches the articulated-skill angular tolerance used by +# OpenDoor and Twist; see BENCHMARK_STANDARD.md for the measured basis. +AXIS_ALIGN_TOLERANCE_RAD = 0.15 + + +@dataclass(frozen=True) +class AxisAlignCase: + """AxisAlign target-axis benchmark case.""" + + name: str + target_axis: tuple[float, float, float] + + +AXIS_ALIGN_CASES: dict[str, AxisAlignCase] = { + "upright": AxisAlignCase("upright", (0.0, 0.0, 1.0)), + "horizontal": AxisAlignCase("horizontal", (0.0, 1.0, 0.0)), +} +DEFAULT_AXIS_ALIGN_CASES = tuple(AXIS_ALIGN_CASES.keys()) +SMOKE_AXIS_ALIGN_CASE = "upright" + + +def add_benchmark_args(parser: argparse.ArgumentParser) -> None: + """Add AxisAlign benchmark CLI arguments.""" + parser.add_argument( + "--align_cases", + nargs="+", + choices=(*AXIS_ALIGN_CASES.keys(), "all"), + default=list(DEFAULT_AXIS_ALIGN_CASES), + help="Target-axis alignment cases to benchmark. Use 'all' for every case.", + ) + add_grasp_benchmark_args(parser) + add_common_benchmark_args(parser) + + +def _parse_args() -> argparse.Namespace: + """Parse command line arguments.""" + parser = argparse.ArgumentParser( + description="Benchmark AxisAlign over target-axis alignment cases." + ) + add_benchmark_args(parser) + return parser.parse_args() + + +def _select_cases(case_names: list[str]) -> list[AxisAlignCase]: + """Resolve selected alignment case names.""" + if "all" in case_names: + return list(AXIS_ALIGN_CASES.values()) + return [AXIS_ALIGN_CASES[name] for name in case_names] + + +def object_axis_angle_rad(obj, internal_axis, target_axis) -> float: + """Return the angle between the object's internal axis and a target axis. + + Args: + obj: Rigid object whose world rotation carries the internal axis. + internal_axis: Object-local axis tensor with shape ``(3,)``. + target_axis: World-frame target axis tensor with shape ``(3,)``. + + Returns: + Angle in radians, in ``[0, pi]``. + """ + torch = ensure_torch() + rotation = obj.get_local_pose(to_matrix=True)[0, :3, :3] + world_axis = rotation.to(internal_axis.dtype) @ internal_axis + world_axis = world_axis / torch.linalg.vector_norm(world_axis) + target = target_axis / torch.linalg.vector_norm(target_axis) + sin_angle = torch.linalg.vector_norm(torch.linalg.cross(world_axis, target)) + cos_angle = torch.dot(world_axis, target) + return float(torch.atan2(sin_angle, cos_angle)) + + +def _build_invocation( + atomic_engine, semantics, case: AxisAlignCase, device, physics_dt +): + """Build the AxisAlign invocation and planning context for one case.""" + torch = ensure_torch() + from embodichain.lab.sim.atomic_actions import ( + AxisAlignGoal, + AxisAlignOptions, + MotionPolicy, + ) + + invocation = atomic_engine.make_invocation( + "axis_align", + AxisAlignGoal(semantics), + control_parts={"primary": {"motion": "arm", "grasp": "hand"}}, + motion_policy=MotionPolicy( + strategy="motion_gen", sample_count=ALIGN_SAMPLE_INTERVAL + ), + skill_options=AxisAlignOptions( + target_axis=torch.tensor( + case.target_axis, dtype=torch.float32, device=device + ), + approach_direction=torch.tensor( + [0.0, 0.0, -1.0], dtype=torch.float32, device=device + ), + pre_grasp_distance=PRE_GRASP_DISTANCE, + lift_height=LIFT_HEIGHT, + hand_interp_steps=HAND_INTERP_STEPS, + ), + ) + return invocation, atomic_engine.initial_context(control_dt=physics_dt) + + +def _run_case( + sim, + robot, + obj, + atomic_engine, + semantics, + initial_qpos, + initial_object_pose, + case: AxisAlignCase, + repeat: int, + args: argparse.Namespace, + recorded_count: int, +) -> dict[str, object]: + """Run one AxisAlign case through the full four-level success ladder.""" + torch = ensure_torch() + ladder = SuccessLadder() + reset_robot(robot, initial_qpos) + reset_rigid_object(obj, initial_object_pose) + sim.update(step=10) + + internal_axis = torch.tensor(INTERNAL_AXIS, dtype=torch.float32, device=sim.device) + target_axis = torch.tensor(case.target_axis, dtype=torch.float32, device=sim.device) + initial_angle = object_axis_angle_rad(obj, internal_axis, target_axis) + + invocation, context = _build_invocation( + atomic_engine, semantics, case, sim.device, sim.sim_config.physics_dt + ) + try: + elapsed, mem_delta, peak_gpu, result = timed_call( + lambda: atomic_engine.compile((invocation,), context) + ) + except Exception as exc: + print(f" planner exception: {type(exc).__name__}: {exc}") + ladder.fail("planning_success", "planner_exception") + return _case_result(case, repeat, ladder, 0.0, None, None, initial_angle, "") + + ladder.planning_success = bool(result.plan_success.all().item()) + traj = result.trajectory.positions + if not ladder.planning_success: + ladder.fail("planning_success", "planner_reported_failure") + return _case_result( + case, repeat, ladder, elapsed, mem_delta, None, initial_angle, "", peak_gpu + ) + + motion_valid, motion_reason = check_motion_valid(traj, robot) + ladder.motion_valid = motion_valid + if not motion_valid: + ladder.fail("motion_valid", motion_reason) + return _case_result( + case, + repeat, + ladder, + elapsed, + mem_delta, + None, + initial_angle, + "", + peak_gpu, + traj, + ) + + try: + measure_waypoint = result.segment(0, "manipulate").stop - 1 + except (KeyError, AttributeError, IndexError): + measure_waypoint = int(traj.shape[1]) - 1 + + try: + trace = replay_and_track_scalar( + sim=sim, + robot=robot, + traj=traj, + read_value=lambda _index: object_axis_angle_rad( + obj, internal_axis, target_axis + ), + measure_waypoint=measure_waypoint, + steps_per_waypoint=DEFAULT_REPLAY_STEPS_PER_WAYPOINT, + hold_steps=REPLAY_HOLD_STEPS, + ) + ladder.execution_success = trace is not None + except Exception as exc: + print(f" replay exception: {type(exc).__name__}: {exc}") + trace = None + ladder.fail("execution_success", "controller_tracking_failure") + + if trace is not None and ( + trace.max_tracking_error_rad > REPLAY_TRACKING_TOLERANCE_RAD + ): + ladder.execution_success = False + ladder.fail("execution_success", "controller_tracking_failure") + elif ladder.execution_success and trace is not None: + # The tracked scalar is already the axis angle error to the target. + ladder.task_success = trace.measured_position <= AXIS_ALIGN_TOLERANCE_RAD + if not ladder.task_success: + ladder.fail("task_success", "task_goal_miss") + elif not ladder.failure_stage: + ladder.fail("execution_success", "controller_tracking_failure") + + video_path = "" + if should_record_case(args, recorded_count, ladder.task_success): + reset_robot(robot, initial_qpos) + reset_rigid_object(obj, initial_object_pose) + recorded = replay_trajectory_with_recording( + sim=sim, + robot=robot, + traj=traj, + args=args, + video_path=build_video_output_path( + args, "atomic_action_axis_align", f"{case.name}_r{repeat}" + ), + ) + video_path = str(recorded) if recorded is not None else "" + + return _case_result( + case, + repeat, + ladder, + elapsed, + mem_delta, + trace, + initial_angle, + video_path, + peak_gpu, + traj, + ) + + +def _case_result( + case: AxisAlignCase, + repeat: int, + ladder: SuccessLadder, + elapsed: float, + mem_delta: dict[str, float] | None, + trace, + initial_angle: float, + video_path: str, + peak_gpu: float = 0.0, + traj=None, +) -> dict[str, object]: + """Assemble one AxisAlign case result row.""" + mem_delta = mem_delta or {"cpu_mb": 0.0, "gpu_mb": 0.0} + return { + "case_id": f"{case.name}:r{repeat}", + "align_case": case.name, + "repeat": repeat, + "ladder": ladder, + "target_axis": str(case.target_axis), + "initial_axis_angle_rad": initial_angle, + "cost_time_ms": elapsed * 1000.0, + "cpu_delta_mb": mem_delta["cpu_mb"], + "gpu_delta_mb": mem_delta["gpu_mb"], + "peak_gpu_mb": peak_gpu, + "max_tracking_error_rad": trace.max_tracking_error_rad if trace else None, + "final_axis_angle_rad": trace.measured_position if trace else None, + "settled_axis_angle_rad": trace.settled_position if trace else None, + "trajectory_waypoints": ( + int(traj.shape[1]) if traj is not None and traj.ndim >= 3 else 0 + ), + "success": ladder.task_success, + "video_path": video_path, + } + + +def _build_rows(results: list[dict[str, object]]): + """Convert case results into report row dicts.""" + perf_rows: list[dict[str, object]] = [] + metric_rows: list[dict[str, object]] = [] + for result in results: + perf_rows.append( + { + "sample_size": 1, + "impl": "axis_align", + "case_id": result["case_id"], + "align_case": result["align_case"], + "repeat": result["repeat"], + "cost_time_ms": format_float(result["cost_time_ms"], precision=2), + "cpu_delta_mb": format_float(result["cpu_delta_mb"], precision=1), + "gpu_delta_mb": format_float(result["gpu_delta_mb"], precision=1), + "peak_gpu_mb": format_float(result["peak_gpu_mb"], precision=1), + "trajectory_waypoints": result["trajectory_waypoints"], + } + ) + ladder: SuccessLadder = result["ladder"] # type: ignore[assignment] + metric_rows.append( + { + "sample_size": 1, + "impl": "axis_align", + "case_id": result["case_id"], + "align_case": result["align_case"], + "target_axis": result["target_axis"], + "success_rate": f"{float(ladder.task_success):.6f}", + **ladder.as_row_fields(), + "max_tracking_error_rad": format_float( + result["max_tracking_error_rad"], 4 + ), + "initial_axis_angle_rad": format_float( + result["initial_axis_angle_rad"], 4 + ), + "final_axis_angle_rad": format_float(result["final_axis_angle_rad"], 4), + "settled_axis_angle_rad": format_float( + result["settled_axis_angle_rad"], 4 + ), + } + ) + return perf_rows, metric_rows + + +def run_all_benchmarks(args: argparse.Namespace | None = None) -> Path: + """Run AxisAlign benchmark and write a markdown report.""" + args = _parse_args() if args is None else args + if args.repeat < 1: + raise ValueError("--repeat must be at least 1.") + profile = resolve_profile(args) + ensure_repo_root() + ensure_torch() + from embodichain.lab.sim.atomic_actions import ControlPartCommandProfile + from embodichain.lab.sim.atomic_actions.sim_adapter import ( + create_simulation_atomic_action_engine, + ) + from scripts.tutorials.atomic_action.axis_align import ( + create_align_object, + create_axis_align_semantics, + ) + from scripts.tutorials.atomic_action.tutorial_utils import ( + add_ur5_gripper_robot, + create_parallel_jaw_grasp_pose_generator, + create_toppra_motion_generator, + get_hand_open_close_qpos, + initialize_benchmark_simulation, + initialize_pre_pick_robot_pose, + ) + + cases = _select_cases(args.align_cases) + repeat = 1 if profile == "smoke" else args.repeat + if profile == "smoke": + cases = [AXIS_ALIGN_CASES[SMOKE_AXIS_ALIGN_CASE]] + + print("=" * 60) + print("AxisAlign Atomic Action Benchmark") + print("=" * 60) + print( + f"Coverage: profile={profile}, {len(cases)} align case(s) x {repeat} repeat(s)" + ) + + sim = initialize_benchmark_simulation(args) + robot = add_ur5_gripper_robot(sim, tcp_z=0.15) + obj = create_align_object(sim) + sim.prepare() + sim.update(step=10) + hand_open, hand_close = get_hand_open_close_qpos(robot) + # AxisAlign plans from the tutorial's pre-pick posture. + initialize_pre_pick_robot_pose(robot, obj, hand_open) + initial_qpos = robot.get_qpos().clone() + initial_object_pose = obj.get_local_pose(to_matrix=True).clone() + + n_sample = 1000 if profile == "smoke" else args.n_sample + # AxisAlign resolves the object pose from the scene by entity id, so the + # engine must be bound to the simulation entity rather than constructed bare. + atomic_engine = create_simulation_atomic_action_engine( + motion_generator=create_toppra_motion_generator(robot), + scene_entities=(obj,), + control_profiles={ + "hand": ControlPartCommandProfile.joint_positions( + open=hand_open, grasp=hand_close + ) + }, + grasp_pose_generators={ + "hand": create_parallel_jaw_grasp_pose_generator( + n_sample=n_sample, force_refresh=args.force_reannotate + ) + }, + ) + semantics = create_axis_align_semantics(obj, INTERNAL_AXIS) + + print("Warm-up: running one discarded compile to exclude first-call cost...") + warmup_invocation, warmup_context = _build_invocation( + atomic_engine, semantics, cases[0], sim.device, sim.sim_config.physics_dt + ) + warmup_planning(lambda: atomic_engine.compile((warmup_invocation,), warmup_context)) + reset_robot(robot, initial_qpos) + reset_rigid_object(obj, initial_object_pose) + + results: list[dict[str, object]] = [] + video_paths: list[str] = [] + print("\n=== AxisAlign Target-Axis Sweep ===") + for case in cases: + for repeat_index in range(repeat): + result = _run_case( + sim, + robot, + obj, + atomic_engine, + semantics, + initial_qpos, + initial_object_pose, + case, + repeat_index, + args, + len(video_paths), + ) + results.append(result) + if result["video_path"]: + video_paths.append(str(result["video_path"])) + ladder: SuccessLadder = result["ladder"] # type: ignore[assignment] + print( + f" {result['case_id']:<22} " + f"time={result['cost_time_ms']:>9.2f} ms | " + f"plan={ladder.planning_success} valid={ladder.motion_valid} " + f"exec={ladder.execution_success} task={ladder.task_success} " + f"angle0={format_float(result['initial_axis_angle_rad'], 4)} " + f"angle={format_float(result['final_axis_angle_rad'], 4)} " + f"[{ladder.failure_reason or 'ok'}]" + ) + + perf_rows, metric_rows = _build_rows(results) + leaderboard_rows = build_ladder_leaderboard("axis_align", results) + report_path = write_markdown_report( + benchmark_name="atomic_action_axis_align", + perf_rows=perf_rows, + metric_rows=metric_rows, + leaderboard_rows=leaderboard_rows, + notes=[ + f"Profile: {profile}", + f"CPU memory backend: {CPU_MEMORY_BACKEND}", + f"Grasp samples: {n_sample}", + f"Object internal axis: {INTERNAL_AXIS}.", + "task_success requires the angle between the object's internal axis " + "and the commanded target axis to be at most " + f"{AXIS_ALIGN_TOLERANCE_RAD:.3f} rad " + f"({math.degrees(AXIS_ALIGN_TOLERANCE_RAD):.1f} deg) at the end of " + "the commanded 'manipulate' segment, while the object is still held.", + "initial_axis_angle_rad is the angle before the skill runs; a case " + "only demonstrates alignment when it starts meaningfully misaligned.", + "Planning time excludes a discarded warm-up compile and includes " + "grasp-pose generation.", + *summarize_video_recording(args, results, video_paths), + ], + ) + print(f"Markdown report saved: {report_path}") + return report_path + + +def main() -> None: + """Run the CLI entry point.""" + try: + run_all_benchmarks() + except RuntimeError as exc: + raise SystemExit(str(exc)) from exc + + +if __name__ == "__main__": + from scripts.tutorials.atomic_action.tutorial_utils import run_tutorial + + run_tutorial(main) + + +__all__ = ["add_benchmark_args", "object_axis_angle_rad", "run_all_benchmarks"] diff --git a/scripts/benchmark/atomic_action/common.py b/scripts/benchmark/atomic_action/common.py index bfd726ca3..64bad2f58 100644 --- a/scripts/benchmark/atomic_action/common.py +++ b/scripts/benchmark/atomic_action/common.py @@ -575,6 +575,9 @@ def create_benchmark_object( asset_physics_mode=preset.asset_physics_mode, ) obj = sim.add_rigid_object(cfg=cfg) + # Adding a body changes the spawn topology; re-prepare so the new object's + # body_data is bound before any benchmark reads its pose. + sim.prepare() sim.update(step=10) return obj @@ -1095,6 +1098,537 @@ def record_static_scene_video( return None +FAILURE_TAXONOMY: tuple[str, ...] = ( + "invalid_case", + "unsupported_capability", + "checkpoint_load_failure", + "planner_exception", + "planner_reported_failure", + "timeout", + "non_finite_trajectory", + "waypoint_miss", + "joint_limit_violation", + "dynamic_limit_violation", + "self_collision", + "environment_collision", + "controller_tracking_failure", + "object_not_grasped", + "object_dropped", + "release_failure", + "task_goal_miss", +) +LADDER_STAGES: tuple[str, ...] = ( + "planning_success", + "motion_valid", + "execution_success", + "task_success", +) +JOINT_LIMIT_TOLERANCE_RAD = 1e-3 +# Physics steps per replayed waypoint. The planners emit one waypoint per +# physics step, but the simulated position drive cannot converge that fast: a +# measured sweep on OpenDoor gave max arm tracking errors of 1.38/0.92/0.44/ +# 0.16/0.05/0.02 rad at 1/2/4/8/16/32 steps per waypoint, and the measured +# hinge error tracked it (0.23/0.13/0.07/0.05/0.03/0.02 rad). At 16 steps the +# target-joint measurement has converged to within 0.006 rad of the 32-step +# value, so the replay measures the skill rather than the drive. +DEFAULT_REPLAY_STEPS_PER_WAYPOINT = 16 +# Maximum arm tracking error tolerated before a case is reported as a +# controller failure instead of crediting or blaming the skill. +REPLAY_TRACKING_TOLERANCE_RAD = 0.10 + + +@dataclass +class SuccessLadder: + """Layered atomic-action outcome shared by every skill benchmark. + + The stages follow the suite protocol in + ``scripts/benchmark/motion_generation/BENCHMARK_DESIGN.md`` section 7: + ``planning_success -> motion_valid -> execution_success -> task_success``. + Every stage is recorded, not only the final verdict. + """ + + planning_success: bool = False + motion_valid: bool = False + execution_success: bool = False + task_success: bool = False + failure_stage: str = "" + failure_reason: str = "" + + def fail(self, stage: str, reason: str) -> "SuccessLadder": + """Record the first failing stage and its taxonomy reason. + + Args: + stage: Ladder stage name from :data:`LADDER_STAGES`. + reason: Failure reason from :data:`FAILURE_TAXONOMY`. + + Returns: + This ladder, to allow ``return ladder.fail(...)``. + """ + if stage not in LADDER_STAGES: + raise ValueError(f"Unknown ladder stage {stage!r}.") + if reason not in FAILURE_TAXONOMY: + raise ValueError(f"Unknown failure reason {reason!r}.") + if not self.failure_stage: + self.failure_stage = stage + self.failure_reason = reason + return self + + def as_row_fields(self) -> dict[str, object]: + """Return the ladder columns shared by every benchmark metric table.""" + return { + "planning_success": f"{float(self.planning_success):.6f}", + "motion_valid": f"{float(self.motion_valid):.6f}", + "execution_success": f"{float(self.execution_success):.6f}", + "task_success": f"{float(self.task_success):.6f}", + "failure_stage": self.failure_stage or "N/A", + "failure_reason": self.failure_reason or "N/A", + } + + +def check_motion_valid(traj, robot) -> tuple[bool, str]: + """Validate a planned trajectory independently of any planner's own epsilon. + + A trajectory is valid when it is non-empty, entirely finite, and stays + inside the robot's joint position limits. + + Args: + traj: Planned positions with shape ``(num_envs, num_waypoints, dof)``. + robot: Robot providing ``get_qpos_limits``. + + Returns: + ``(motion_valid, failure_reason)``; the reason is empty when valid. + """ + torch = ensure_torch() + if traj is None or getattr(traj, "ndim", 0) < 3 or traj.shape[1] == 0: + return False, "non_finite_trajectory" + if not bool(torch.isfinite(traj).all()): + return False, "non_finite_trajectory" + + try: + limits = robot.get_qpos_limits() + except Exception: + # Without limits the finite check above is the strongest available + # statement; do not fabricate a limit violation. + return True, "" + + dof = traj.shape[-1] + if limits.shape[1] < dof: + return True, "" + lower = limits[0, :dof, 0].to(traj.device) + upper = limits[0, :dof, 1].to(traj.device) + # Unlimited joints are reported as non-finite bounds; ignore them. + valid = torch.isfinite(lower) & torch.isfinite(upper) + if not bool(valid.any()): + return True, "" + below = traj[..., valid] < (lower[valid] - JOINT_LIMIT_TOLERANCE_RAD) + above = traj[..., valid] > (upper[valid] + JOINT_LIMIT_TOLERANCE_RAD) + if bool(below.any()) or bool(above.any()): + return False, "joint_limit_violation" + return True, "" + + +@dataclass(frozen=True) +class JointDisplacementTrace: + """Signed displacement of one articulation joint across a physical replay. + + Displacement is measured relative to the joint position recorded before the + replay starts, so it is independent of the asset's zero convention. + + ``measured_position`` is the value at the caller-selected evaluation + waypoint. Contact skills must be scored while the robot still controls the + target joint: once the hand releases and the arm retracts, an undriven + hinge, drawer, or knob is free to rebound, and the post-replay resting + value describes the asset's dynamics rather than the skill's achievement. + ``settled_position`` retains that resting value for diagnostics. + """ + + initial_position: float + measured_position: float + settled_position: float + measured_displacement: float + settled_displacement: float + peak_signed_displacement: float + max_tracking_error_rad: float = 0.0 + + +def waypoint_step_counts( + waypoint_dt, + physics_dt: float, + waypoint_count: int, + fallback_steps: int = 4, +) -> list[int]: + """Resolve physics steps per waypoint from a planned trajectory's timing. + + Contact skills are sensitive to replay rate: holding each commanded + waypoint longer than the planner intended keeps pushing a position + controlled arm into the contact, which over-actuates the manipulated + joint. This mirrors the step derivation used by the tutorial replay so + benchmark replays execute at the planned speed. + + Args: + waypoint_dt: Per-waypoint arrival intervals for one environment, or + None to fall back to a fixed rate. + physics_dt: Simulation physics timestep in seconds. + waypoint_count: Number of trajectory waypoints. + fallback_steps: Steps per waypoint used when timing is unavailable. + + Returns: + Physics step counts, one per waypoint, each at least one. + """ + if waypoint_dt is None or physics_dt <= 0.0: + return [fallback_steps] * waypoint_count + + counts: list[int] = [] + for index in range(waypoint_count): + next_index = min(index + 1, waypoint_count - 1) + try: + duration = float(waypoint_dt[next_index]) + except (IndexError, TypeError, ValueError): + counts.append(fallback_steps) + continue + ratio = duration / physics_dt + nearest = round(ratio) + if math.isclose(ratio, nearest, rel_tol=1.0e-6, abs_tol=1.0e-9): + counts.append(max(1, int(nearest))) + else: + counts.append(max(1, math.ceil(ratio))) + return counts + + +def replay_and_track_scalar( + sim, + robot, + traj, + read_value: Callable[[int], float], + measure_waypoint: int | None = None, + waypoint_dt=None, + physics_dt: float = 0.0, + steps_per_waypoint: int = DEFAULT_REPLAY_STEPS_PER_WAYPOINT, + hold_steps: int = 60, + hold_substeps: int = 2, +) -> JointDisplacementTrace | None: + """Replay a trajectory while sampling one scalar scene measurement. + + This is the shared physical-replay primitive for every skill benchmark. + ``read_value`` may read an articulation joint, an object tilt angle, or any + other scalar the skill's task-success rule needs. + + Args: + sim: Simulation manager to step. + robot: Robot receiving the replayed joint positions. + traj: Planned positions with shape ``(num_envs, num_waypoints, dof)``. + read_value: Returns the tracked scalar at the current simulation + state, given the waypoint index just executed. The index is ``-1`` + before the first waypoint and ``waypoint_count`` during the hold, + which lets a skill capture a reference at a segment boundary. + measure_waypoint: Waypoint index scored as the skill's achievement. + Defaults to the final waypoint. + waypoint_dt: Planned per-waypoint arrival intervals for one + environment. When given, the replay runs at the planned rate. + physics_dt: Simulation physics timestep, required with ``waypoint_dt``. + steps_per_waypoint: Fallback physics steps per waypoint when no + planned timing is available. + hold_steps: Terminal hold iterations after the last waypoint. + hold_substeps: Physics steps per terminal hold iteration. + + Returns: + The measurement trace, or None when the trajectory is unusable. + """ + if traj is None or getattr(traj, "ndim", 0) < 3 or traj.shape[1] == 0: + return None + + waypoint_count = int(traj.shape[1]) + if measure_waypoint is None: + measure_waypoint = waypoint_count - 1 + measure_waypoint = max(0, min(int(measure_waypoint), waypoint_count - 1)) + + step_counts = waypoint_step_counts( + waypoint_dt, physics_dt, waypoint_count, steps_per_waypoint + ) + + initial_position = read_value(-1) + samples = [initial_position] + measured_position = initial_position + max_tracking_error = 0.0 + for waypoint_index in range(waypoint_count): + commanded = traj[:, waypoint_index, :] + robot.set_qpos(commanded) + sim.update(step=step_counts[waypoint_index]) + # Verify the arm actually reached the commanded waypoint. Scoring the + # scene is only meaningful when the executed motion matches the planned + # one; a large error here means the replay measured the controller. + achieved = robot.get_qpos(target=False) + error = float((achieved - commanded).abs().max()) + max_tracking_error = max(max_tracking_error, error) + value = read_value(waypoint_index) + samples.append(value) + if waypoint_index == measure_waypoint: + measured_position = value + + final_qpos = traj[:, -1, :] + for _ in range(hold_steps): + robot.set_qpos(final_qpos) + sim.update(step=hold_substeps) + samples.append(read_value(waypoint_count)) + + settled_position = samples[-1] + displacements = [value - initial_position for value in samples] + peak = max(displacements, key=abs) + return JointDisplacementTrace( + initial_position=initial_position, + measured_position=measured_position, + settled_position=settled_position, + measured_displacement=measured_position - initial_position, + settled_displacement=settled_position - initial_position, + peak_signed_displacement=peak, + max_tracking_error_rad=max_tracking_error, + ) + + +def replay_and_track_joint( + sim, + robot, + traj, + articulation, + joint_index: int, + measure_waypoint: int | None = None, + waypoint_dt=None, + physics_dt: float = 0.0, + steps_per_waypoint: int = DEFAULT_REPLAY_STEPS_PER_WAYPOINT, + hold_steps: int = 60, + hold_substeps: int = 2, +) -> JointDisplacementTrace | None: + """Replay a trajectory in physics while tracking one articulation joint. + + Both the actuation waypoints and the terminal hold are sampled, so a joint + that reaches its extreme mid-motion and then rebounds (a button, a + free-swinging door) still reports the peak it actually attained. + + Args: + sim: Simulation manager to step. + robot: Robot receiving the replayed joint positions. + traj: Planned positions with shape ``(num_envs, num_waypoints, dof)``. + articulation: Articulation owning the tracked joint. + joint_index: Index of the tracked joint in ``articulation``. + measure_waypoint: Waypoint index scored as the skill's achievement. + Defaults to the final waypoint. + waypoint_dt: Planned per-waypoint arrival intervals for one + environment. When given, the replay runs at the planned rate. + physics_dt: Simulation physics timestep, required with ``waypoint_dt``. + steps_per_waypoint: Fallback physics steps per waypoint when no + planned timing is available. + hold_steps: Terminal hold iterations after the last waypoint. + hold_substeps: Physics steps per terminal hold iteration. + + Returns: + The displacement trace, or None when the trajectory is unusable. + """ + + def read(waypoint_index: int) -> float: + del waypoint_index + return float(articulation.get_qpos(target=False)[0, joint_index]) + + return replay_and_track_scalar( + sim=sim, + robot=robot, + traj=traj, + read_value=read, + measure_waypoint=measure_waypoint, + waypoint_dt=waypoint_dt, + physics_dt=physics_dt, + steps_per_waypoint=steps_per_waypoint, + hold_steps=hold_steps, + hold_substeps=hold_substeps, + ) + + +@dataclass +class ContactCaseOutcome: + """Result of running one articulated-contact case through the ladder.""" + + ladder: SuccessLadder + trace: JointDisplacementTrace | None = None + elapsed_s: float = 0.0 + cpu_delta_mb: float = 0.0 + gpu_delta_mb: float = 0.0 + peak_gpu_mb: float = 0.0 + trajectory = None + + @property + def trajectory_waypoints(self) -> int: + """Number of planned waypoints, or zero when planning failed.""" + traj = self.trajectory + if traj is None or getattr(traj, "ndim", 0) < 3: + return 0 + return int(traj.shape[1]) + + +def run_articulated_contact_case( + sim, + robot, + articulation, + atomic_engine, + build_invocation: Callable[[], tuple[object, object]], + joint_index: int, + actuation_segment: str, + evaluate: Callable[[JointDisplacementTrace], tuple[bool, str]], + hold_steps: int = 60, +) -> ContactCaseOutcome: + """Run one contact skill through planning, validation, and physical replay. + + The target joint is scored at the last waypoint of ``actuation_segment``, + the point where the skill stops commanding the joint. Later segments open + the hand and retract the arm, after which an undriven hinge, drawer, or + knob moves under its own dynamics rather than the skill's control. + + Args: + sim: Simulation manager to step. + robot: Robot executing the trajectory. + articulation: Articulation owning the manipulated joint. + atomic_engine: Engine used to compile the invocation. + build_invocation: Returns the ``(invocation, context)`` pair to compile. + joint_index: Index of the manipulated joint in ``articulation``. + actuation_segment: Segment name whose end is scored. + evaluate: Maps the displacement trace to ``(task_success, reason)``. + hold_steps: Terminal hold iterations after the last waypoint. + + Returns: + The ladder result, displacement trace, and cost metrics for the case. + """ + ladder = SuccessLadder() + outcome = ContactCaseOutcome(ladder=ladder) + + invocation, context = build_invocation() + try: + elapsed, mem_delta, peak_gpu, result = timed_call( + lambda: atomic_engine.compile((invocation,), context) + ) + except Exception as exc: + print(f" planner exception: {type(exc).__name__}: {exc}") + ladder.fail("planning_success", "planner_exception") + return outcome + + outcome.elapsed_s = elapsed + outcome.cpu_delta_mb = mem_delta["cpu_mb"] + outcome.gpu_delta_mb = mem_delta["gpu_mb"] + outcome.peak_gpu_mb = peak_gpu + + ladder.planning_success = bool(result.plan_success.all().item()) + traj = result.trajectory.positions + outcome.trajectory = traj + if not ladder.planning_success: + ladder.fail("planning_success", "planner_reported_failure") + return outcome + + motion_valid, motion_reason = check_motion_valid(traj, robot) + ladder.motion_valid = motion_valid + if not motion_valid: + ladder.fail("motion_valid", motion_reason) + return outcome + + try: + measure_waypoint = result.segment(0, actuation_segment).stop - 1 + except (KeyError, AttributeError, IndexError): + measure_waypoint = int(traj.shape[1]) - 1 + + try: + trace = replay_and_track_joint( + sim=sim, + robot=robot, + traj=traj, + articulation=articulation, + joint_index=joint_index, + measure_waypoint=measure_waypoint, + steps_per_waypoint=DEFAULT_REPLAY_STEPS_PER_WAYPOINT, + hold_steps=hold_steps, + ) + ladder.execution_success = trace is not None + except Exception as exc: + print(f" replay exception: {type(exc).__name__}: {exc}") + trace = None + ladder.fail("execution_success", "controller_tracking_failure") + + outcome.trace = trace + if trace is not None and ( + trace.max_tracking_error_rad > REPLAY_TRACKING_TOLERANCE_RAD + ): + # The executed motion did not match the plan, so the manipulated joint + # reading describes the drive, not the skill. Do not score task success. + ladder.execution_success = False + ladder.fail("execution_success", "controller_tracking_failure") + return outcome + + if ladder.execution_success and trace is not None: + task_success, reason = evaluate(trace) + ladder.task_success = task_success + if not task_success: + ladder.fail("task_success", reason) + elif not ladder.failure_stage: + ladder.fail("execution_success", "controller_tracking_failure") + return outcome + + +def warmup_planning(callable_fn: Callable[[], object]) -> bool: + """Run one discarded planning call so timings exclude first-call cost. + + The first compile in a process pays Warp/torch kernel compilation and + allocator warm-up, which would otherwise be charged to whichever case + happens to run first and make cases incomparable. + + Args: + callable_fn: Planning call to execute and discard. + + Returns: + True when the warm-up call completed without raising. + """ + try: + callable_fn() + sync_cuda() + return True + except Exception as exc: + print(f"Warning: benchmark warm-up call failed: {type(exc).__name__}: {exc}") + return False + + +def build_ladder_leaderboard( + action_name: str, + results: Sequence[dict[str, object]], +) -> list[dict[str, object]]: + """Aggregate ladder stage rates for one action into a leaderboard row. + + Args: + action_name: Atomic action identifier. + results: Case results each carrying a ``ladder`` entry. + + Returns: + A single-row leaderboard reporting every ladder stage rate. + """ + if not results: + return [] + + count = len(results) + + def rate(stage: str) -> str: + total = sum( + 1.0 + for result in results + if bool(getattr(result["ladder"], stage)) # type: ignore[index] + ) + return f"{total / count:.2%}" + + return [ + { + "rank": 1, + "algorithm": action_name, + "planning_success_rate": rate("planning_success"), + "motion_valid_rate": rate("motion_valid"), + "execution_success_rate": rate("execution_success"), + "task_success_rate": rate("task_success"), + "evaluated_cases": count, + } + ] + + def format_float(value: float | None, precision: int = 6) -> str: """Format finite floats for tables and use N/A for missing values.""" if value is None or not math.isfinite(value): @@ -1185,7 +1719,10 @@ def build_single_action_leaderboard( "add_profile_benchmark_args", "add_video_benchmark_args", "build_video_output_path", + "build_ladder_leaderboard", "build_single_action_leaderboard", + "check_motion_valid", + "ContactCaseOutcome", "create_antipodal_object_semantics", "create_benchmark_object", "create_mesh_benchmark_object", @@ -1196,10 +1733,14 @@ def build_single_action_leaderboard( "describe_object_preset", "ensure_repo_root", "ensure_torch", + "FAILURE_TAXONOMY", "format_float", "format_vector3", "FULL_MESH_OBJECT_TYPES", "FULL_POSITION_CASE_NAMES", + "JointDisplacementTrace", + "JOINT_LIMIT_TOLERANCE_RAD", + "LADDER_STAGES", "MESH_OBJECT_PRESETS", "MeshObjectPreset", "PICKUP_APPROACH_CASES", @@ -1213,12 +1754,15 @@ def build_single_action_leaderboard( "park_rigid_object", "pickup_approach_direction_tuple", "record_static_scene_video", + "replay_and_track_joint", + "replay_and_track_scalar", "replay_trajectory_for_physical_validation", "replay_trajectory_with_recording", "reset_rigid_object", "reset_rigid_object_xy", "reset_robot", "resolve_pickup_approach_direction", + "run_articulated_contact_case", "resolve_profile", "select_mesh_object_presets", "select_pickup_approaches", @@ -1229,7 +1773,10 @@ def build_single_action_leaderboard( "SMOKE_PICKUP_APPROACH_CASES", "SMOKE_MESH_OBJECT_TYPES", "SMOKE_POSITION_CASE_NAMES", + "SuccessLadder", "timed_call", + "warmup_planning", + "waypoint_step_counts", "summarize_video_recording", "write_markdown_report", "xy_distance_m", diff --git a/scripts/benchmark/atomic_action/hand_over_benchmark.py b/scripts/benchmark/atomic_action/hand_over_benchmark.py new file mode 100644 index 000000000..bcc728158 --- /dev/null +++ b/scripts/benchmark/atomic_action/hand_over_benchmark.py @@ -0,0 +1,573 @@ +# ---------------------------------------------------------------------------- +# Copyright (c) 2021-2026 DexForce Technology Co., Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ---------------------------------------------------------------------------- + +"""Benchmark HandOver atomic action on the dual-arm tutorial scene. + +Reports the four-level success ladder (planning_success, motion_valid, +execution_success, task_success). Task success requires the object to reach the +commanded delivery pose without ever being dropped during the transfer. +Run: embodichain benchmark atomic-action --action hand_over +""" + +from __future__ import annotations + +import argparse +from dataclasses import dataclass +from pathlib import Path + +from scripts.benchmark.atomic_action.common import ( + CPU_MEMORY_BACKEND, + DEFAULT_REPLAY_STEPS_PER_WAYPOINT, + REPLAY_TRACKING_TOLERANCE_RAD, + SuccessLadder, + add_common_benchmark_args, + add_grasp_benchmark_args, + build_ladder_leaderboard, + build_video_output_path, + check_motion_valid, + ensure_repo_root, + ensure_torch, + format_float, + object_position_tuple, + replay_and_track_scalar, + replay_trajectory_with_recording, + reset_rigid_object, + reset_robot, + resolve_profile, + should_record_case, + summarize_video_recording, + timed_call, + warmup_planning, + write_markdown_report, + xyz_distance_m, +) + +DUAL_ROBOT_TYPE = "ur5" +GRIPPER_TCP_Z = 0.15 +HAND_CLOSE_QPOS = 0.04 +HANDOVER_SAMPLE_INTERVAL = 220 +HANDOVER_HAND_INTERP_STEPS = 10 +HANDOVER_PRE_GRASP_DISTANCE = 0.08 +HANDOVER_LIFT_HEIGHT = 0.15 +SUPPORT_SURFACE_Z = 0.50 +FINAL_OBJECT_XYZ = (0.0, -0.2, 0.6) +SETTLE_ITERATIONS = 50 +REPLAY_HOLD_STEPS = 60 +# Delivery tolerance on the object's final position. Matches the magnitude of +# the existing MoveHeldObject placement tolerance (0.12 m) used by this suite. +HANDOVER_DELIVERY_TOLERANCE_M = 0.12 +# The object is dropped if it ever falls well below the support surface it was +# lifted from; 0.15 m under the surface clears normal transfer dips. +HANDOVER_DROP_Z_M = SUPPORT_SURFACE_Z - 0.15 + + +@dataclass(frozen=True) +class HandOverCase: + """HandOver object-orientation benchmark case.""" + + name: str + is_horizontal: bool + + +HAND_OVER_CASES: dict[str, HandOverCase] = { + "vertical_can": HandOverCase("vertical_can", False), + "horizontal_pencil": HandOverCase("horizontal_pencil", True), +} +DEFAULT_HAND_OVER_CASES = tuple(HAND_OVER_CASES.keys()) +SMOKE_HAND_OVER_CASE = "vertical_can" + + +def add_benchmark_args(parser: argparse.ArgumentParser) -> None: + """Add HandOver benchmark CLI arguments.""" + parser.add_argument( + "--hand_over_cases", + nargs="+", + choices=(*HAND_OVER_CASES.keys(), "all"), + default=list(DEFAULT_HAND_OVER_CASES), + help="Object-orientation cases to benchmark. Use 'all' for every case.", + ) + add_grasp_benchmark_args(parser) + add_common_benchmark_args(parser) + + +def _parse_args() -> argparse.Namespace: + """Parse command line arguments.""" + parser = argparse.ArgumentParser( + description="Benchmark HandOver over dual-arm transfer cases." + ) + add_benchmark_args(parser) + return parser.parse_args() + + +def _select_cases(case_names: list[str]) -> list[HandOverCase]: + """Resolve selected handover case names.""" + if "all" in case_names: + return list(HAND_OVER_CASES.values()) + return [HAND_OVER_CASES[name] for name in case_names] + + +def _build_invocation(atomic_engine, semantics, device, physics_dt): + """Build the HandOver invocation and planning context.""" + torch = ensure_torch() + from embodichain.lab.sim.atomic_actions import ( + HandOverGoal, + HandOverOptions, + MotionPolicy, + ) + + final_pose = torch.eye(4, dtype=torch.float32, device=device) + final_pose[:3, 3] = torch.as_tensor( + FINAL_OBJECT_XYZ, dtype=torch.float32, device=device + ) + invocation = atomic_engine.make_invocation( + "hand_over", + HandOverGoal(semantics, target_pose=final_pose), + control_parts={ + "source": {"motion": "left_arm", "grasp": "left_hand"}, + "destination": {"motion": "right_arm", "grasp": "right_hand"}, + }, + motion_policy=MotionPolicy( + strategy="motion_gen", sample_count=HANDOVER_SAMPLE_INTERVAL + ), + skill_options=HandOverOptions( + pre_grasp_distance=HANDOVER_PRE_GRASP_DISTANCE, + lift_height=HANDOVER_LIFT_HEIGHT, + hand_interp_steps=HANDOVER_HAND_INTERP_STEPS, + ), + ) + return invocation, atomic_engine.initial_context(control_dt=physics_dt) + + +def _run_case( + sim, + robot, + obj, + atomic_engine, + semantics, + initial_qpos, + initial_object_pose, + case: HandOverCase, + repeat: int, + args: argparse.Namespace, + recorded_count: int, +) -> dict[str, object]: + """Run one HandOver case through the full four-level success ladder.""" + ladder = SuccessLadder() + reset_robot(robot, initial_qpos) + reset_rigid_object(obj, initial_object_pose) + sim.update(step=10) + + invocation, context = _build_invocation( + atomic_engine, semantics, sim.device, sim.sim_config.physics_dt + ) + try: + elapsed, mem_delta, peak_gpu, result = timed_call( + lambda: atomic_engine.compile((invocation,), context) + ) + except Exception as exc: + print(f" planner exception: {type(exc).__name__}: {exc}") + ladder.fail("planning_success", "planner_exception") + return _case_result(case, repeat, ladder, 0.0, None, None, None, None, "") + + ladder.planning_success = bool(result.plan_success.all().item()) + traj = result.trajectory.positions + if not ladder.planning_success: + for plan in result.action_plans: + if not plan.plan_success.all(): + messages = plan.diagnostics.messages or ("planning failed",) + print(f" plan failure [{plan.skill_id}]: {'; '.join(messages)}") + ladder.fail("planning_success", "planner_reported_failure") + return _case_result( + case, repeat, ladder, elapsed, mem_delta, None, None, None, "", peak_gpu + ) + + motion_valid, motion_reason = check_motion_valid(traj, robot) + ladder.motion_valid = motion_valid + if not motion_valid: + ladder.fail("motion_valid", motion_reason) + return _case_result( + case, + repeat, + ladder, + elapsed, + mem_delta, + None, + None, + None, + "", + peak_gpu, + traj, + ) + + state: dict[str, float] = {"min_z": float("inf")} + + def read_delivery_distance(waypoint_index: int) -> float: + del waypoint_index + position = object_position_tuple(obj) + state["min_z"] = min(state["min_z"], position[2]) + return xyz_distance_m(position, FINAL_OBJECT_XYZ) + + try: + trace = replay_and_track_scalar( + sim=sim, + robot=robot, + traj=traj, + read_value=read_delivery_distance, + steps_per_waypoint=DEFAULT_REPLAY_STEPS_PER_WAYPOINT, + hold_steps=REPLAY_HOLD_STEPS, + ) + ladder.execution_success = trace is not None + except Exception as exc: + print(f" replay exception: {type(exc).__name__}: {exc}") + trace = None + ladder.fail("execution_success", "controller_tracking_failure") + + min_z = state["min_z"] if state["min_z"] != float("inf") else None + dropped = min_z is not None and min_z < HANDOVER_DROP_Z_M + if trace is not None and ( + trace.max_tracking_error_rad > REPLAY_TRACKING_TOLERANCE_RAD + ): + ladder.execution_success = False + ladder.fail("execution_success", "controller_tracking_failure") + elif ladder.execution_success and trace is not None: + delivered = trace.settled_position <= HANDOVER_DELIVERY_TOLERANCE_M + ladder.task_success = delivered and not dropped + if not ladder.task_success: + ladder.fail( + "task_success", "object_dropped" if dropped else "task_goal_miss" + ) + elif not ladder.failure_stage: + ladder.fail("execution_success", "controller_tracking_failure") + + video_path = "" + if should_record_case(args, recorded_count, ladder.task_success): + reset_robot(robot, initial_qpos) + reset_rigid_object(obj, initial_object_pose) + recorded = replay_trajectory_with_recording( + sim=sim, + robot=robot, + traj=traj, + args=args, + video_path=build_video_output_path( + args, "atomic_action_hand_over", f"{case.name}_r{repeat}" + ), + ) + video_path = str(recorded) if recorded is not None else "" + + return _case_result( + case, + repeat, + ladder, + elapsed, + mem_delta, + trace, + min_z, + dropped, + video_path, + peak_gpu, + traj, + ) + + +def _release_simulation(sim) -> None: + """Tear down one case's simulation so the next case can build its own.""" + from embodichain.lab.sim import SimulationManager + + if not SimulationManager.is_instantiated(): + return + if not getattr(sim, "_is_constructed", False): + SimulationManager.reset(getattr(sim, "instance_id", 0)) + return + if sim.is_window_recording(): + sim.stop_window_record() + sim.wait_window_record_saves() + sim.destroy(exit_process=False) + SimulationManager.flush_cleanup_queue() + + +def _case_result( + case: HandOverCase, + repeat: int, + ladder: SuccessLadder, + elapsed: float, + mem_delta: dict[str, float] | None, + trace, + min_object_z: float | None, + dropped: bool | None, + video_path: str, + peak_gpu: float = 0.0, + traj=None, +) -> dict[str, object]: + """Assemble one HandOver case result row.""" + mem_delta = mem_delta or {"cpu_mb": 0.0, "gpu_mb": 0.0} + return { + "case_id": f"{case.name}:r{repeat}", + "hand_over_case": case.name, + "repeat": repeat, + "ladder": ladder, + "cost_time_ms": elapsed * 1000.0, + "cpu_delta_mb": mem_delta["cpu_mb"], + "gpu_delta_mb": mem_delta["gpu_mb"], + "peak_gpu_mb": peak_gpu, + "max_tracking_error_rad": trace.max_tracking_error_rad if trace else None, + "initial_delivery_distance_m": trace.initial_position if trace else None, + "final_delivery_distance_m": trace.settled_position if trace else None, + "min_object_z_m": min_object_z, + "object_dropped": bool(dropped) if dropped is not None else None, + "trajectory_waypoints": ( + int(traj.shape[1]) if traj is not None and traj.ndim >= 3 else 0 + ), + "success": ladder.task_success, + "video_path": video_path, + } + + +def _build_rows(results: list[dict[str, object]]): + """Convert case results into report row dicts.""" + perf_rows: list[dict[str, object]] = [] + metric_rows: list[dict[str, object]] = [] + for result in results: + perf_rows.append( + { + "sample_size": 1, + "impl": "hand_over", + "case_id": result["case_id"], + "hand_over_case": result["hand_over_case"], + "repeat": result["repeat"], + "cost_time_ms": format_float(result["cost_time_ms"], precision=2), + "cpu_delta_mb": format_float(result["cpu_delta_mb"], precision=1), + "gpu_delta_mb": format_float(result["gpu_delta_mb"], precision=1), + "peak_gpu_mb": format_float(result["peak_gpu_mb"], precision=1), + "trajectory_waypoints": result["trajectory_waypoints"], + } + ) + ladder: SuccessLadder = result["ladder"] # type: ignore[assignment] + metric_rows.append( + { + "sample_size": 1, + "impl": "hand_over", + "case_id": result["case_id"], + "hand_over_case": result["hand_over_case"], + "success_rate": f"{float(ladder.task_success):.6f}", + **ladder.as_row_fields(), + "max_tracking_error_rad": format_float( + result["max_tracking_error_rad"], 4 + ), + "initial_delivery_distance_m": format_float( + result["initial_delivery_distance_m"], 4 + ), + "final_delivery_distance_m": format_float( + result["final_delivery_distance_m"], 4 + ), + "min_object_z_m": format_float(result["min_object_z_m"], 4), + "object_dropped": result["object_dropped"], + } + ) + return perf_rows, metric_rows + + +def run_all_benchmarks(args: argparse.Namespace | None = None) -> Path: + """Run HandOver benchmark and write a markdown report.""" + args = _parse_args() if args is None else args + if args.repeat < 1: + raise ValueError("--repeat must be at least 1.") + profile = resolve_profile(args) + ensure_repo_root() + ensure_torch() + from embodichain.lab.sim.atomic_actions import ControlPartCommandProfile + from embodichain.lab.sim.atomic_actions.sim_adapter import ( + create_simulation_atomic_action_engine, + ) + from scripts.tutorials.atomic_action.hand_over import ( + create_dual_robot, + create_handover_object, + create_support_surface, + ) + from scripts.tutorials.atomic_action.scenario_utils import settle_object + from scripts.tutorials.atomic_action.tutorial_utils import ( + clone_local_pose_from_first_env, + create_antipodal_semantics, + create_parallel_jaw_grasp_pose_generator, + create_toppra_motion_generator, + create_tutorial_simulation, + get_hand_open_close_qpos, + ) + + cases = _select_cases(args.hand_over_cases) + repeat = 1 if profile == "smoke" else args.repeat + if profile == "smoke": + cases = [HAND_OVER_CASES[SMOKE_HAND_OVER_CASE]] + + print("=" * 60) + print("HandOver Atomic Action Benchmark") + print("=" * 60) + print( + f"Coverage: profile={profile}, {len(cases)} handover case(s) x " + f"{repeat} repeat(s)" + ) + if len(cases) > 1: + print( + "Note: object orientation is fixed at scene construction, so each " + "case is run in its own simulation." + ) + + report_results: list[dict[str, object]] = [] + video_paths: list[str] = [] + n_sample = 1000 if profile == "smoke" else args.n_sample + + # The handover object's mesh and orientation are chosen when the scene is + # built, so each case owns a simulation rather than resetting a shared one. + for case in cases: + sim = create_tutorial_simulation( + argparse.Namespace( + num_envs=getattr(args, "num_envs", 1), + device=getattr(args, "device", "cpu"), + renderer=getattr(args, "renderer", "auto"), + headless=True, + ), + arena_space=3.0, + ) + try: + robot = create_dual_robot(sim, DUAL_ROBOT_TYPE) + create_support_surface(sim) + obj = create_handover_object( + sim, argparse.Namespace(is_horizontal=case.is_horizontal) + ) + sim.prepare() + settle_object(sim, obj, step=0) + clone_local_pose_from_first_env(obj) + obj.clear_dynamics() + # The tutorial lets the object fall onto the support surface before + # planning; reproduce that settling. + for _ in range(SETTLE_ITERATIONS): + sim.update(step=10) + + semantics = create_antipodal_semantics(obj, label="handover") + left_open, left_close = get_hand_open_close_qpos( + robot, hand_control_part="left_hand", close_qpos=HAND_CLOSE_QPOS + ) + right_open, right_close = get_hand_open_close_qpos( + robot, hand_control_part="right_hand", close_qpos=HAND_CLOSE_QPOS + ) + grasp_pose_generator = create_parallel_jaw_grasp_pose_generator( + n_sample=n_sample, force_refresh=args.force_reannotate + ) + atomic_engine = create_simulation_atomic_action_engine( + motion_generator=create_toppra_motion_generator(robot), + scene_entities=(obj,), + control_profiles={ + "left_hand": ControlPartCommandProfile.joint_positions( + open=left_open, grasp=left_close + ), + "right_hand": ControlPartCommandProfile.joint_positions( + open=right_open, grasp=right_close + ), + }, + grasp_pose_generators={ + "left_hand": grasp_pose_generator, + "right_hand": grasp_pose_generator, + }, + ) + initial_qpos = robot.get_qpos().clone() + initial_object_pose = obj.get_local_pose(to_matrix=True).clone() + + print(f"\n=== HandOver case {case.name} ===") + print("Warm-up: running one discarded compile...") + warmup_invocation, warmup_context = _build_invocation( + atomic_engine, semantics, sim.device, sim.sim_config.physics_dt + ) + warmup_planning( + lambda: atomic_engine.compile((warmup_invocation,), warmup_context) + ) + reset_robot(robot, initial_qpos) + reset_rigid_object(obj, initial_object_pose) + + for repeat_index in range(repeat): + result = _run_case( + sim, + robot, + obj, + atomic_engine, + semantics, + initial_qpos, + initial_object_pose, + case, + repeat_index, + args, + len(video_paths), + ) + report_results.append(result) + if result["video_path"]: + video_paths.append(str(result["video_path"])) + ladder: SuccessLadder = result["ladder"] # type: ignore[assignment] + print( + f" {result['case_id']:<26} " + f"time={result['cost_time_ms']:>9.2f} ms | " + f"plan={ladder.planning_success} valid={ladder.motion_valid} " + f"exec={ladder.execution_success} task={ladder.task_success} " + f"dist={format_float(result['final_delivery_distance_m'], 4)} " + f"min_z={format_float(result['min_object_z_m'], 3)} " + f"[{ladder.failure_reason or 'ok'}]" + ) + finally: + # SimulationManager is a singleton, so a multi-case sweep must tear + # the current scene down before the next case builds its own. + _release_simulation(sim) + + perf_rows, metric_rows = _build_rows(report_results) + leaderboard_rows = build_ladder_leaderboard("hand_over", report_results) + report_path = write_markdown_report( + benchmark_name="atomic_action_hand_over", + perf_rows=perf_rows, + metric_rows=metric_rows, + leaderboard_rows=leaderboard_rows, + notes=[ + f"Profile: {profile}", + f"CPU memory backend: {CPU_MEMORY_BACKEND}", + f"Grasp samples: {n_sample}", + f"Dual-arm robot: {DUAL_ROBOT_TYPE}, source=left_arm/left_hand, " + "destination=right_arm/right_hand.", + "task_success requires the object to settle within " + f"{HANDOVER_DELIVERY_TOLERANCE_M:.3f} m of the commanded delivery " + f"pose {FINAL_OBJECT_XYZ} and never to fall below " + f"{HANDOVER_DROP_Z_M:.3f} m during the transfer.", + "object_dropped uses the minimum object height observed across the " + "whole replay, so a mid-transfer drop is caught even if the object " + "later comes to rest near the target.", + "Planning time excludes a discarded warm-up compile and includes " + "grasp-pose generation.", + *summarize_video_recording(args, report_results, video_paths), + ], + ) + print(f"Markdown report saved: {report_path}") + return report_path + + +def main() -> None: + """Run the CLI entry point.""" + try: + run_all_benchmarks() + except RuntimeError as exc: + raise SystemExit(str(exc)) from exc + + +if __name__ == "__main__": + from scripts.tutorials.atomic_action.tutorial_utils import run_tutorial + + run_tutorial(main) + + +__all__ = ["add_benchmark_args", "run_all_benchmarks"] diff --git a/scripts/benchmark/atomic_action/open_door_benchmark.py b/scripts/benchmark/atomic_action/open_door_benchmark.py new file mode 100644 index 000000000..6a7fe366d --- /dev/null +++ b/scripts/benchmark/atomic_action/open_door_benchmark.py @@ -0,0 +1,538 @@ +# ---------------------------------------------------------------------------- +# Copyright (c) 2021-2026 DexForce Technology Co., Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ---------------------------------------------------------------------------- + +"""Benchmark OpenDoor atomic action across microwave hinge opening targets. + +Reports the four-level success ladder (planning_success, motion_valid, +execution_success, task_success) defined in +``scripts/benchmark/motion_generation/BENCHMARK_DESIGN.md``. Task success +requires the physically replayed hinge to reach the commanded opening angle. +Run: embodichain benchmark atomic-action --action open_door +""" + +from __future__ import annotations + +import argparse +import math +from dataclasses import dataclass +from pathlib import Path + +from scripts.benchmark.atomic_action.common import ( + CPU_MEMORY_BACKEND, + DEFAULT_REPLAY_STEPS_PER_WAYPOINT, + REPLAY_TRACKING_TOLERANCE_RAD, + SuccessLadder, + add_common_benchmark_args, + add_grasp_benchmark_args, + build_ladder_leaderboard, + build_video_output_path, + check_motion_valid, + ensure_repo_root, + ensure_torch, + format_float, + replay_and_track_joint, + replay_trajectory_with_recording, + reset_robot, + resolve_profile, + should_record_case, + summarize_video_recording, + timed_call, + warmup_planning, + write_markdown_report, +) + + +@dataclass(frozen=True) +class DoorCase: + """OpenDoor hinge target benchmark case.""" + + name: str + open_angle_deg: float + + +DOOR_CASES: dict[str, DoorCase] = { + "open_30": DoorCase("open_30", 30.0), + "open_60": DoorCase("open_60", 60.0), + "open_90": DoorCase("open_90", 90.0), +} +DEFAULT_DOOR_CASES = tuple(DOOR_CASES.keys()) +SMOKE_DOOR_CASE = "open_60" +TRAJECTORY_SAMPLE_COUNT = 120 +HAND_INTERP_STEPS = 12 +DOOR_WAYPOINT_COUNT = 50 +REPLAY_HOLD_STEPS = 60 +# Measured hinge error of a healthy replay is ~0.02 rad; 0.15 rad (8.6 deg) +# keeps that comfortably inside while still rejecting a door that never moved. +HINGE_SUCCESS_TOLERANCE_RAD = 0.15 + + +def add_benchmark_args(parser: argparse.ArgumentParser) -> None: + """Add OpenDoor benchmark CLI arguments.""" + parser.add_argument( + "--door_cases", + nargs="+", + choices=(*DOOR_CASES.keys(), "all"), + default=list(DEFAULT_DOOR_CASES), + help="Hinge opening cases to benchmark. Use 'all' for every case.", + ) + add_grasp_benchmark_args(parser) + add_common_benchmark_args(parser) + + +def _parse_args() -> argparse.Namespace: + """Parse command line arguments.""" + parser = argparse.ArgumentParser( + description="Benchmark OpenDoor over microwave hinge opening targets." + ) + add_benchmark_args(parser) + return parser.parse_args() + + +def _select_cases(case_names: list[str]) -> list[DoorCase]: + """Resolve selected door case names.""" + if "all" in case_names: + return list(DOOR_CASES.values()) + return [DOOR_CASES[name] for name in case_names] + + +def _reset_scene(robot, microwave, initial_qpos, initial_hinge_qpos) -> None: + """Restore the robot and the microwave hinge to their initial state.""" + reset_robot(robot, initial_qpos) + microwave.set_qpos(initial_hinge_qpos, target=False) + microwave.set_qpos(initial_hinge_qpos, target=True) + microwave.clear_dynamics() + + +def _build_invocation(atomic_engine, microwave, case: DoorCase, physics_dt: float): + """Build the OpenDoor invocation and planning context for one case.""" + from embodichain.lab.sim.atomic_actions import ( + MotionPolicy, + ObservedArticulationJointState, + OpenDoorGoal, + OpenDoorOptions, + SceneSnapshot, + ) + from scripts.tutorials.atomic_action.open_door import ( + HANDLE_LINK_NAME, + MICROWAVE_SCENE_ENTITY_ID, + create_door_handle_semantics, + ) + + semantics = create_door_handle_semantics(microwave) + affordance = semantics.affordance + lower_limit, upper_limit = affordance.joint_limits + closed_position = lower_limit if affordance.opening_direction > 0 else upper_limit + open_position = upper_limit if affordance.opening_direction > 0 else lower_limit + open_angle = math.radians(case.open_angle_deg) + open_fraction = (open_angle - closed_position) / (open_position - closed_position) + + handle_pose = microwave.get_link_pose(HANDLE_LINK_NAME, to_matrix=True) + hinge_joint_index = microwave.joint_names.index(affordance.joint_name) + hinge_position = microwave.get_qpos(target=False)[ + :, hinge_joint_index : hinge_joint_index + 1 + ] + + invocation = atomic_engine.make_invocation( + "open_door", + OpenDoorGoal(semantics, handle_pose, open_fraction=open_fraction), + control_parts={"primary": {"motion": "arm", "grasp": "hand"}}, + motion_policy=MotionPolicy(sample_count=TRAJECTORY_SAMPLE_COUNT), + skill_options=OpenDoorOptions( + hand_interp_steps=HAND_INTERP_STEPS, + door_waypoint_count=DOOR_WAYPOINT_COUNT, + ), + ) + # OpenDoor resolves the hinge goal against the observed joint state, so the + # planning context must carry it exactly as the tutorial does. + context = atomic_engine.initial_context( + scene=SceneSnapshot( + timestamp=0.0, + version=0, + articulation_joints={ + ( + MICROWAVE_SCENE_ENTITY_ID, + affordance.joint_name, + ): ObservedArticulationJointState(hinge_position) + }, + ), + control_dt=physics_dt, + ) + return invocation, context, hinge_joint_index, open_angle + + +def _door_measure_waypoint(compiled, traj) -> int: + """Return the last waypoint of the commanded door-actuation segment.""" + try: + return compiled.segment(0, "open").stop - 1 + except (KeyError, AttributeError, IndexError): + return int(traj.shape[1]) - 1 + + +def _run_case( + sim, + robot, + microwave, + atomic_engine, + initial_qpos, + initial_hinge_qpos, + case: DoorCase, + repeat: int, + args: argparse.Namespace, + recorded_count: int, +) -> dict[str, object]: + """Run one OpenDoor case through the full four-level success ladder.""" + ladder = SuccessLadder() + _reset_scene(robot, microwave, initial_qpos, initial_hinge_qpos) + sim.update(step=4) + + invocation, context, hinge_joint_index, open_angle = _build_invocation( + atomic_engine, microwave, case, sim.sim_config.physics_dt + ) + + try: + elapsed, mem_delta, peak_gpu, result = timed_call( + lambda: atomic_engine.compile((invocation,), context) + ) + except Exception as exc: + print(f" planner exception: {type(exc).__name__}: {exc}") + ladder.fail("planning_success", "planner_exception") + return _case_result(case, repeat, ladder, 0.0, None, None, open_angle, "") + + ladder.planning_success = bool(result.plan_success.all().item()) + traj = result.trajectory.positions + if not ladder.planning_success: + ladder.fail("planning_success", "planner_reported_failure") + return _case_result( + case, repeat, ladder, elapsed, mem_delta, None, open_angle, "", peak_gpu + ) + + motion_valid, motion_reason = check_motion_valid(traj, robot) + ladder.motion_valid = motion_valid + if not motion_valid: + ladder.fail("motion_valid", motion_reason) + return _case_result( + case, + repeat, + ladder, + elapsed, + mem_delta, + None, + open_angle, + "", + peak_gpu, + traj, + ) + + # Score the hinge at the end of the commanded door-actuation segment. After + # the release and retract segments the hand is open and this microwave's + # hinge is undriven, so it swings freely and its resting angle measures the + # asset's dynamics rather than what OpenDoor achieved. + measure_waypoint = _door_measure_waypoint(result, traj) + + try: + trace = replay_and_track_joint( + sim=sim, + robot=robot, + traj=traj, + articulation=microwave, + joint_index=hinge_joint_index, + measure_waypoint=measure_waypoint, + # This door swing needs a slower replay than the shared default. + # At 16 the arm trails the plan by 0.66 rad and the tracking + # gate rejects the run; 64 keeps it inside the gate in both + # profiles and lands the hinge within 0.001 rad of the command. + steps_per_waypoint=4 * DEFAULT_REPLAY_STEPS_PER_WAYPOINT, + hold_steps=REPLAY_HOLD_STEPS, + ) + ladder.execution_success = trace is not None + except Exception as exc: + print(f" replay exception: {type(exc).__name__}: {exc}") + trace = None + ladder.fail("execution_success", "controller_tracking_failure") + + if trace is not None and ( + trace.max_tracking_error_rad > REPLAY_TRACKING_TOLERANCE_RAD + ): + # The arm did not follow the plan, so the hinge reading describes the + # drive rather than OpenDoor. Do not score task success. + ladder.execution_success = False + ladder.fail("execution_success", "controller_tracking_failure") + elif ladder.execution_success and trace is not None: + # The hinge target is an absolute angle, so compare the replayed + # absolute position rather than the displacement. + hinge_error = abs(trace.measured_position - open_angle) + ladder.task_success = hinge_error <= HINGE_SUCCESS_TOLERANCE_RAD + if not ladder.task_success: + ladder.fail("task_success", "task_goal_miss") + elif not ladder.failure_stage: + ladder.fail("execution_success", "controller_tracking_failure") + + video_path = "" + if should_record_case(args, recorded_count, ladder.task_success): + _reset_scene(robot, microwave, initial_qpos, initial_hinge_qpos) + recorded = replay_trajectory_with_recording( + sim=sim, + robot=robot, + traj=traj, + args=args, + video_path=build_video_output_path( + args, "atomic_action_open_door", f"{case.name}_r{repeat}" + ), + ) + video_path = str(recorded) if recorded is not None else "" + + return _case_result( + case, + repeat, + ladder, + elapsed, + mem_delta, + trace, + open_angle, + video_path, + peak_gpu, + traj, + ) + + +def _case_result( + case: DoorCase, + repeat: int, + ladder: SuccessLadder, + elapsed: float, + mem_delta: dict[str, float] | None, + trace, + open_angle: float, + video_path: str, + peak_gpu: float = 0.0, + traj=None, +) -> dict[str, object]: + """Assemble one OpenDoor case result row.""" + mem_delta = mem_delta or {"cpu_mb": 0.0, "gpu_mb": 0.0} + hinge_error = None + if trace is not None: + hinge_error = abs(trace.measured_position - open_angle) + return { + "case_id": f"{case.name}:r{repeat}", + "door_case": case.name, + "repeat": repeat, + "ladder": ladder, + "target_angle_rad": math.radians(case.open_angle_deg), + "cost_time_ms": elapsed * 1000.0, + "cpu_delta_mb": mem_delta["cpu_mb"], + "gpu_delta_mb": mem_delta["gpu_mb"], + "peak_gpu_mb": peak_gpu, + "max_tracking_error_rad": trace.max_tracking_error_rad if trace else None, + "hinge_initial_rad": trace.initial_position if trace else None, + "hinge_measured_rad": trace.measured_position if trace else None, + "hinge_settled_rad": trace.settled_position if trace else None, + "hinge_peak_signed_rad": trace.peak_signed_displacement if trace else None, + "hinge_error_rad": hinge_error, + "trajectory_waypoints": ( + int(traj.shape[1]) if traj is not None and traj.ndim >= 3 else 0 + ), + "success": ladder.task_success, + "video_path": video_path, + } + + +def _build_rows(results: list[dict[str, object]]): + """Convert case results into report row dicts.""" + perf_rows: list[dict[str, object]] = [] + metric_rows: list[dict[str, object]] = [] + for result in results: + perf_rows.append( + { + "sample_size": 1, + "impl": "open_door", + "case_id": result["case_id"], + "door_case": result["door_case"], + "repeat": result["repeat"], + "cost_time_ms": format_float(result["cost_time_ms"], precision=2), + "cpu_delta_mb": format_float(result["cpu_delta_mb"], precision=1), + "gpu_delta_mb": format_float(result["gpu_delta_mb"], precision=1), + "peak_gpu_mb": format_float(result["peak_gpu_mb"], precision=1), + "trajectory_waypoints": result["trajectory_waypoints"], + } + ) + ladder: SuccessLadder = result["ladder"] # type: ignore[assignment] + metric_rows.append( + { + "sample_size": 1, + "impl": "open_door", + "case_id": result["case_id"], + "door_case": result["door_case"], + "success_rate": f"{float(ladder.task_success):.6f}", + **ladder.as_row_fields(), + "target_angle_rad": format_float(result["target_angle_rad"], 4), + "max_tracking_error_rad": format_float( + result["max_tracking_error_rad"], 4 + ), + "hinge_initial_rad": format_float(result["hinge_initial_rad"], 4), + "hinge_measured_rad": format_float(result["hinge_measured_rad"], 4), + "hinge_peak_signed_rad": format_float( + result["hinge_peak_signed_rad"], 4 + ), + "hinge_settled_rad": format_float(result["hinge_settled_rad"], 4), + "hinge_error_rad": format_float(result["hinge_error_rad"], 4), + } + ) + return perf_rows, metric_rows + + +def run_all_benchmarks(args: argparse.Namespace | None = None) -> Path: + """Run OpenDoor benchmark and write a markdown report.""" + args = _parse_args() if args is None else args + if args.repeat < 1: + raise ValueError("--repeat must be at least 1.") + profile = resolve_profile(args) + ensure_repo_root() + ensure_torch() + from embodichain.lab.sim.atomic_actions import ( + AtomicActionEngine, + ControlPartCommandProfile, + ) + from scripts.tutorials.atomic_action.open_door import create_microwave + from scripts.tutorials.atomic_action.tutorial_utils import ( + add_ur5_gripper_robot, + create_parallel_jaw_grasp_pose_generator, + create_toppra_motion_generator, + get_hand_open_close_qpos, + initialize_benchmark_simulation, + ) + + cases = _select_cases(args.door_cases) + repeat = 1 if profile == "smoke" else args.repeat + if profile == "smoke": + cases = [DOOR_CASES[SMOKE_DOOR_CASE]] + + print("=" * 60) + print("OpenDoor Atomic Action Benchmark") + print("=" * 60) + print( + f"Coverage: profile={profile}, {len(cases)} door case(s) x {repeat} repeat(s)" + ) + + sim = initialize_benchmark_simulation(args) + robot = add_ur5_gripper_robot( + sim, + init_qpos=[0.0, -1.57, 1.57, -3.14, -1.57, 0.0, 0.0, 0.0], + tcp_z=0.15, + ) + microwave = create_microwave(sim) + sim.prepare() + sim.update(step=10) + initial_qpos = robot.get_qpos().clone() + initial_hinge_qpos = microwave.get_qpos(target=False).clone() + hand_open, hand_close = get_hand_open_close_qpos(robot, close_qpos=0.04) + + n_sample = 1000 if profile == "smoke" else args.n_sample + atomic_engine = AtomicActionEngine( + motion_generator=create_toppra_motion_generator(robot), + control_profiles={ + "hand": ControlPartCommandProfile.joint_positions( + open=hand_open, + grasp=hand_close, + ) + }, + grasp_pose_generators={ + "hand": create_parallel_jaw_grasp_pose_generator( + n_sample=n_sample, + force_refresh=args.force_reannotate, + ) + }, + ) + + warmup_case = cases[0] + warmup_invocation, warmup_context, _, _ = _build_invocation( + atomic_engine, microwave, warmup_case, sim.sim_config.physics_dt + ) + print("Warm-up: running one discarded compile to exclude first-call cost...") + warmup_planning(lambda: atomic_engine.compile((warmup_invocation,), warmup_context)) + _reset_scene(robot, microwave, initial_qpos, initial_hinge_qpos) + + results: list[dict[str, object]] = [] + video_paths: list[str] = [] + print("\n=== OpenDoor Hinge Target Sweep ===") + for case in cases: + for repeat_index in range(repeat): + result = _run_case( + sim, + robot, + microwave, + atomic_engine, + initial_qpos, + initial_hinge_qpos, + case, + repeat_index, + args, + len(video_paths), + ) + results.append(result) + if result["video_path"]: + video_paths.append(str(result["video_path"])) + ladder: SuccessLadder = result["ladder"] # type: ignore[assignment] + print( + f" {result['case_id']:<20} " + f"time={result['cost_time_ms']:>9.2f} ms | " + f"plan={ladder.planning_success} valid={ladder.motion_valid} " + f"exec={ladder.execution_success} task={ladder.task_success} " + f"hinge_err={format_float(result['hinge_error_rad'], 4)} " + f"[{ladder.failure_reason or 'ok'}]" + ) + + perf_rows, metric_rows = _build_rows(results) + leaderboard_rows = build_ladder_leaderboard("open_door", results) + report_path = write_markdown_report( + benchmark_name="atomic_action_open_door", + perf_rows=perf_rows, + metric_rows=metric_rows, + leaderboard_rows=leaderboard_rows, + notes=[ + f"Profile: {profile}", + f"CPU memory backend: {CPU_MEMORY_BACKEND}", + f"Grasp samples: {n_sample}", + "task_success requires the physically replayed hinge angle to reach " + f"the commanded target within {HINGE_SUCCESS_TOLERANCE_RAD} rad, " + "measured at the last waypoint of the commanded door-actuation " + "('open') segment.", + "hinge_settled_rad is the resting angle after release and retract. " + "This microwave hinge is undriven (drive_type='none'), so it swings " + "freely once released; that value is diagnostic, not a success gate.", + "Planning time excludes a discarded warm-up compile and includes " + "handle grasp-pose generation.", + *summarize_video_recording(args, results, video_paths), + ], + ) + print(f"Markdown report saved: {report_path}") + return report_path + + +def main() -> None: + """Run the CLI entry point.""" + try: + run_all_benchmarks() + except RuntimeError as exc: + raise SystemExit(str(exc)) from exc + + +if __name__ == "__main__": + from scripts.tutorials.atomic_action.tutorial_utils import run_tutorial + + run_tutorial(main) + + +__all__ = ["add_benchmark_args", "run_all_benchmarks"] diff --git a/scripts/benchmark/atomic_action/pour_benchmark.py b/scripts/benchmark/atomic_action/pour_benchmark.py new file mode 100644 index 000000000..869595ddc --- /dev/null +++ b/scripts/benchmark/atomic_action/pour_benchmark.py @@ -0,0 +1,577 @@ +# ---------------------------------------------------------------------------- +# Copyright (c) 2021-2026 DexForce Technology Co., Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ---------------------------------------------------------------------------- + +"""Benchmark Pour atomic action as a PickUp-then-Pour sequence. + +Reports the four-level success ladder (planning_success, motion_valid, +execution_success, task_success). Pour rotates a held object about its own +internal axis, so task success is measured as the signed rotation the object +actually turned about that axis during the commanded pour segment. +Run: embodichain benchmark atomic-action --action pour +""" + +from __future__ import annotations + +import argparse +import math +from dataclasses import dataclass +from pathlib import Path + +from scripts.benchmark.atomic_action.common import ( + CPU_MEMORY_BACKEND, + DEFAULT_REPLAY_STEPS_PER_WAYPOINT, + REPLAY_TRACKING_TOLERANCE_RAD, + SuccessLadder, + add_common_benchmark_args, + add_grasp_benchmark_args, + build_ladder_leaderboard, + build_video_output_path, + check_motion_valid, + ensure_repo_root, + ensure_torch, + format_float, + replay_and_track_scalar, + replay_trajectory_with_recording, + reset_rigid_object, + reset_robot, + resolve_profile, + should_record_case, + summarize_video_recording, + timed_call, + warmup_planning, + write_markdown_report, +) + +POUR_INTERNAL_AXIS = (1.0, 0.0, 0.0) +APPROACH_DIRECTION = (-0.707, 0.0, -0.707) +OBJ_POSITION = (-0.5, 0.0, 0.0) +PICK_SAMPLE_INTERVAL = 120 +POUR_SAMPLE_INTERVAL = 80 +HAND_INTERP_STEPS = 12 +PRE_GRASP_DISTANCE = 0.15 +LIFT_HEIGHT = 0.16 +REPLAY_HOLD_STEPS = 60 +# Commanded-versus-achieved pour rotation tolerance. Uses the same angular +# tolerance as the other rotation skills; see BENCHMARK_STANDARD.md. +POUR_TOLERANCE_RAD = 0.15 + + +@dataclass(frozen=True) +class PourCase: + """Pour commanded-rotation benchmark case.""" + + name: str + rotate_angle_rad: float + + +POUR_CASES: dict[str, PourCase] = { + "pour_30": PourCase("pour_30", math.radians(30.0)), + "pour_45": PourCase("pour_45", math.radians(45.0)), + "pour_90": PourCase("pour_90", math.radians(90.0)), +} +DEFAULT_POUR_CASES = tuple(POUR_CASES.keys()) +SMOKE_POUR_CASE = "pour_45" + + +def add_benchmark_args(parser: argparse.ArgumentParser) -> None: + """Add Pour benchmark CLI arguments.""" + parser.add_argument( + "--pour_cases", + nargs="+", + choices=(*POUR_CASES.keys(), "all"), + default=list(DEFAULT_POUR_CASES), + help="Commanded pour-rotation cases to benchmark. Use 'all' for every case.", + ) + add_grasp_benchmark_args(parser) + add_common_benchmark_args(parser) + + +def _parse_args() -> argparse.Namespace: + """Parse command line arguments.""" + parser = argparse.ArgumentParser( + description="Benchmark Pour over commanded rotation angles." + ) + add_benchmark_args(parser) + return parser.parse_args() + + +def _select_cases(case_names: list[str]) -> list[PourCase]: + """Resolve selected pour case names.""" + if "all" in case_names: + return list(POUR_CASES.values()) + return [POUR_CASES[name] for name in case_names] + + +def signed_rotation_about_axis_rad(rotation, reference_rotation, axis) -> float: + """Return the signed rotation from a reference orientation about an axis. + + Pour turns the held object about the object's own internal axis, which that + rotation leaves invariant. Measuring the axis direction is therefore + useless; the achieved pour is the rotation angle of the relative transform + projected onto the axis. + + Args: + rotation: Current object rotation matrix with shape ``(3, 3)``. + reference_rotation: Rotation captured when the pour segment started. + axis: World-frame pour axis with shape ``(3,)``. + + Returns: + Signed rotation in radians about ``axis``. + """ + torch = ensure_torch() + delta = rotation @ reference_rotation.transpose(0, 1) + cos_angle = torch.clamp((torch.diagonal(delta).sum() - 1.0) / 2.0, -1.0, 1.0) + angle = torch.arccos(cos_angle) + # Rotation-vector direction recovers the sign about the requested axis. + vector = torch.stack( + [ + delta[2, 1] - delta[1, 2], + delta[0, 2] - delta[2, 0], + delta[1, 0] - delta[0, 1], + ] + ) + unit_axis = axis / torch.linalg.vector_norm(axis) + sign = torch.sign(torch.dot(vector, unit_axis.to(vector.dtype))) + if float(sign) == 0.0: + sign = torch.ones_like(sign) + return float(angle * sign) + + +def _build_invocations(atomic_engine, semantics, case: PourCase, device, physics_dt): + """Build the PickUp-then-Pour invocations and planning context.""" + torch = ensure_torch() + from embodichain.lab.sim.atomic_actions import ( + GraspGoal, + MotionPolicy, + PickUpOptions, + PourGoal, + PourOptions, + ) + + control_parts = {"primary": {"motion": "arm", "grasp": "hand"}} + pick = atomic_engine.make_invocation( + "pick_up", + GraspGoal(semantics), + control_parts=control_parts, + # Leave the strategy at its default. Forcing strategy="motion_gen" + # here makes PickUp fail to plan on this scene, which is how the Pour + # tutorial's own motion policy breaks. + motion_policy=MotionPolicy(sample_count=PICK_SAMPLE_INTERVAL), + skill_options=PickUpOptions( + approach_direction=torch.tensor( + APPROACH_DIRECTION, dtype=torch.float32, device=device + ), + pre_grasp_distance=PRE_GRASP_DISTANCE, + lift_height=LIFT_HEIGHT, + hand_interp_steps=HAND_INTERP_STEPS, + ), + ) + pour = atomic_engine.make_invocation( + "pour", + PourGoal(), + control_parts=control_parts, + motion_policy=MotionPolicy(sample_count=POUR_SAMPLE_INTERVAL), + skill_options=PourOptions(rotate_angle=case.rotate_angle_rad), + ) + return (pick, pour), atomic_engine.initial_context(control_dt=physics_dt) + + +def _run_case( + sim, + robot, + obj, + atomic_engine, + semantics, + initial_qpos, + initial_object_pose, + case: PourCase, + repeat: int, + args: argparse.Namespace, + recorded_count: int, +) -> dict[str, object]: + """Run one Pour case through the full four-level success ladder.""" + torch = ensure_torch() + ladder = SuccessLadder() + reset_robot(robot, initial_qpos) + reset_rigid_object(obj, initial_object_pose) + sim.update(step=10) + + invocations, context = _build_invocations( + atomic_engine, semantics, case, sim.device, sim.sim_config.physics_dt + ) + try: + elapsed, mem_delta, peak_gpu, result = timed_call( + lambda: atomic_engine.compile(invocations, context) + ) + except Exception as exc: + print(f" planner exception: {type(exc).__name__}: {exc}") + ladder.fail("planning_success", "planner_exception") + return _case_result(case, repeat, ladder, 0.0, None, None, "") + + ladder.planning_success = bool(result.plan_success.all().item()) + traj = result.trajectory.positions + if not ladder.planning_success: + for plan in result.action_plans: + if not plan.plan_success.all(): + messages = plan.diagnostics.messages or ("planning failed",) + print(f" plan failure [{plan.skill_id}]: {'; '.join(messages)}") + ladder.fail("planning_success", "planner_reported_failure") + return _case_result( + case, repeat, ladder, elapsed, mem_delta, None, "", peak_gpu + ) + + motion_valid, motion_reason = check_motion_valid(traj, robot) + ladder.motion_valid = motion_valid + if not motion_valid: + ladder.fail("motion_valid", motion_reason) + return _case_result( + case, repeat, ladder, elapsed, mem_delta, None, "", peak_gpu, traj + ) + + # Pour is the second invocation; score at the end of its own segment. + pour_segment = result.segment(1, "pour") + pour_start = pour_segment.start + measure_waypoint = pour_segment.stop - 1 + + internal_axis = torch.tensor( + POUR_INTERNAL_AXIS, dtype=torch.float32, device=sim.device + ) + state: dict[str, object] = {"reference": None, "axis": None, "pour_start_z": None} + + def read_pour_rotation(waypoint_index: int) -> float: + rotation = obj.get_local_pose(to_matrix=True)[0, :3, :3] + if state["reference"] is None or waypoint_index < pour_start: + # Capture the orientation the pour starts from, so the PickUp + # rotation that precedes it is not counted as pour rotation. + state["reference"] = rotation.clone() + state["axis"] = (rotation.to(internal_axis.dtype) @ internal_axis).clone() + state["pour_start_z"] = float(obj.get_local_pose(to_matrix=True)[0, 2, 3]) + return 0.0 + return signed_rotation_about_axis_rad( + rotation, state["reference"], state["axis"] + ) + + try: + trace = replay_and_track_scalar( + sim=sim, + robot=robot, + traj=traj, + read_value=read_pour_rotation, + measure_waypoint=measure_waypoint, + steps_per_waypoint=DEFAULT_REPLAY_STEPS_PER_WAYPOINT, + hold_steps=REPLAY_HOLD_STEPS, + ) + ladder.execution_success = trace is not None + except Exception as exc: + print(f" replay exception: {type(exc).__name__}: {exc}") + trace = None + ladder.fail("execution_success", "controller_tracking_failure") + + rotation_error = None + if trace is not None and ( + trace.max_tracking_error_rad > REPLAY_TRACKING_TOLERANCE_RAD + ): + ladder.execution_success = False + ladder.fail("execution_success", "controller_tracking_failure") + elif ladder.execution_success and trace is not None: + # Pour rotates to the poured pose and then returns to the pose it + # started from, all inside one "pour" segment, so the end of the + # segment is back at zero rotation by construction. The achieved pour + # is the peak signed rotation reached during the segment. + rotation_error = abs( + abs(trace.peak_signed_displacement) - abs(case.rotate_angle_rad) + ) + ladder.task_success = rotation_error <= POUR_TOLERANCE_RAD + if not ladder.task_success: + ladder.fail("task_success", "task_goal_miss") + elif not ladder.failure_stage: + ladder.fail("execution_success", "controller_tracking_failure") + + video_path = "" + if should_record_case(args, recorded_count, ladder.task_success): + reset_robot(robot, initial_qpos) + reset_rigid_object(obj, initial_object_pose) + recorded = replay_trajectory_with_recording( + sim=sim, + robot=robot, + traj=traj, + args=args, + video_path=build_video_output_path( + args, "atomic_action_pour", f"{case.name}_r{repeat}" + ), + ) + video_path = str(recorded) if recorded is not None else "" + + return _case_result( + case, + repeat, + ladder, + elapsed, + mem_delta, + trace, + video_path, + peak_gpu, + traj, + rotation_error, + state["pour_start_z"], + float(obj.get_local_pose(to_matrix=True)[0, 2, 3]), + ) + + +def _case_result( + case: PourCase, + repeat: int, + ladder: SuccessLadder, + elapsed: float, + mem_delta: dict[str, float] | None, + trace, + video_path: str, + peak_gpu: float = 0.0, + traj=None, + rotation_error: float | None = None, + pour_start_object_z: float | None = None, + final_object_z: float | None = None, +) -> dict[str, object]: + """Assemble one Pour case result row.""" + mem_delta = mem_delta or {"cpu_mb": 0.0, "gpu_mb": 0.0} + return { + "case_id": f"{case.name}:r{repeat}", + "pour_case": case.name, + "repeat": repeat, + "ladder": ladder, + "commanded_rotation_rad": case.rotate_angle_rad, + "cost_time_ms": elapsed * 1000.0, + "cpu_delta_mb": mem_delta["cpu_mb"], + "gpu_delta_mb": mem_delta["gpu_mb"], + "peak_gpu_mb": peak_gpu, + "max_tracking_error_rad": trace.max_tracking_error_rad if trace else None, + "achieved_rotation_rad": trace.measured_position if trace else None, + "peak_rotation_rad": trace.peak_signed_displacement if trace else None, + "settled_rotation_rad": trace.settled_position if trace else None, + "rotation_error_rad": rotation_error, + "pour_start_object_z_m": pour_start_object_z, + "final_object_z_m": final_object_z, + "trajectory_waypoints": ( + int(traj.shape[1]) if traj is not None and traj.ndim >= 3 else 0 + ), + "success": ladder.task_success, + "video_path": video_path, + } + + +def _build_rows(results: list[dict[str, object]]): + """Convert case results into report row dicts.""" + perf_rows: list[dict[str, object]] = [] + metric_rows: list[dict[str, object]] = [] + for result in results: + perf_rows.append( + { + "sample_size": 1, + "impl": "pour", + "case_id": result["case_id"], + "pour_case": result["pour_case"], + "repeat": result["repeat"], + "cost_time_ms": format_float(result["cost_time_ms"], precision=2), + "cpu_delta_mb": format_float(result["cpu_delta_mb"], precision=1), + "gpu_delta_mb": format_float(result["gpu_delta_mb"], precision=1), + "peak_gpu_mb": format_float(result["peak_gpu_mb"], precision=1), + "trajectory_waypoints": result["trajectory_waypoints"], + } + ) + ladder: SuccessLadder = result["ladder"] # type: ignore[assignment] + metric_rows.append( + { + "sample_size": 1, + "impl": "pour", + "case_id": result["case_id"], + "pour_case": result["pour_case"], + "success_rate": f"{float(ladder.task_success):.6f}", + **ladder.as_row_fields(), + "commanded_rotation_rad": format_float( + result["commanded_rotation_rad"], 4 + ), + "max_tracking_error_rad": format_float( + result["max_tracking_error_rad"], 4 + ), + "achieved_rotation_rad": format_float( + result["achieved_rotation_rad"], 4 + ), + "peak_rotation_rad": format_float(result["peak_rotation_rad"], 4), + "settled_rotation_rad": format_float(result["settled_rotation_rad"], 4), + "rotation_error_rad": format_float(result["rotation_error_rad"], 4), + "pour_start_object_z_m": format_float( + result["pour_start_object_z_m"], 4 + ), + "final_object_z_m": format_float(result["final_object_z_m"], 4), + } + ) + return perf_rows, metric_rows + + +def run_all_benchmarks(args: argparse.Namespace | None = None) -> Path: + """Run Pour benchmark and write a markdown report.""" + args = _parse_args() if args is None else args + if args.repeat < 1: + raise ValueError("--repeat must be at least 1.") + profile = resolve_profile(args) + ensure_repo_root() + ensure_torch() + from embodichain.lab.sim.atomic_actions import ControlPartCommandProfile + from embodichain.lab.sim.atomic_actions.sim_adapter import ( + create_simulation_atomic_action_engine, + ) + from scripts.tutorials.atomic_action.axis_align import ( + create_align_object, + create_axis_align_semantics, + ) + from scripts.tutorials.atomic_action.tutorial_utils import ( + add_ur5_gripper_robot, + create_parallel_jaw_grasp_pose_generator, + create_toppra_motion_generator, + get_hand_open_close_qpos, + initialize_benchmark_simulation, + initialize_pre_pick_robot_pose, + ) + + cases = _select_cases(args.pour_cases) + repeat = 1 if profile == "smoke" else args.repeat + if profile == "smoke": + cases = [POUR_CASES[SMOKE_POUR_CASE]] + + print("=" * 60) + print("Pour Atomic Action Benchmark") + print("=" * 60) + print( + f"Coverage: profile={profile}, {len(cases)} pour case(s) x {repeat} repeat(s)" + ) + + sim = initialize_benchmark_simulation(args) + robot = add_ur5_gripper_robot(sim, tcp_z=0.15) + obj = create_align_object(sim, obj_position=OBJ_POSITION) + sim.prepare() + sim.update(step=10) + hand_open, hand_close = get_hand_open_close_qpos(robot) + initialize_pre_pick_robot_pose(robot, obj, hand_open) + initial_qpos = robot.get_qpos().clone() + initial_object_pose = obj.get_local_pose(to_matrix=True).clone() + + n_sample = 1000 if profile == "smoke" else args.n_sample + # Pour resolves the held object from the scene, so bind the engine to it. + atomic_engine = create_simulation_atomic_action_engine( + motion_generator=create_toppra_motion_generator(robot), + scene_entities=(obj,), + control_profiles={ + "hand": ControlPartCommandProfile.joint_positions( + open=hand_open, grasp=hand_close + ) + }, + grasp_pose_generators={ + "hand": create_parallel_jaw_grasp_pose_generator( + n_sample=n_sample, force_refresh=args.force_reannotate + ) + }, + ) + semantics = create_axis_align_semantics(obj, POUR_INTERNAL_AXIS) + + print("Warm-up: running one discarded compile to exclude first-call cost...") + warmup_invocations, warmup_context = _build_invocations( + atomic_engine, semantics, cases[0], sim.device, sim.sim_config.physics_dt + ) + warmup_planning(lambda: atomic_engine.compile(warmup_invocations, warmup_context)) + reset_robot(robot, initial_qpos) + reset_rigid_object(obj, initial_object_pose) + + results: list[dict[str, object]] = [] + video_paths: list[str] = [] + print("\n=== Pour Commanded-Rotation Sweep ===") + for case in cases: + for repeat_index in range(repeat): + result = _run_case( + sim, + robot, + obj, + atomic_engine, + semantics, + initial_qpos, + initial_object_pose, + case, + repeat_index, + args, + len(video_paths), + ) + results.append(result) + if result["video_path"]: + video_paths.append(str(result["video_path"])) + ladder: SuccessLadder = result["ladder"] # type: ignore[assignment] + print( + f" {result['case_id']:<22} " + f"time={result['cost_time_ms']:>9.2f} ms | " + f"plan={ladder.planning_success} valid={ladder.motion_valid} " + f"exec={ladder.execution_success} task={ladder.task_success} " + f"peak={format_float(result['peak_rotation_rad'], 4)} " + f"z0={format_float(result['pour_start_object_z_m'], 3)} " + f"err={format_float(result['rotation_error_rad'], 4)} " + f"[{ladder.failure_reason or 'ok'}]" + ) + + perf_rows, metric_rows = _build_rows(results) + leaderboard_rows = build_ladder_leaderboard("pour", results) + report_path = write_markdown_report( + benchmark_name="atomic_action_pour", + perf_rows=perf_rows, + metric_rows=metric_rows, + leaderboard_rows=leaderboard_rows, + notes=[ + f"Profile: {profile}", + f"CPU memory backend: {CPU_MEMORY_BACKEND}", + f"Grasp samples: {n_sample}", + "Sequence: PickUp then Pour, compiled as one episode. Planning time " + "covers both invocations.", + "task_success requires the peak signed rotation of the object " + "about its own internal axis during the commanded 'pour' segment " + f"to match the commanded angle within {POUR_TOLERANCE_RAD:.3f} rad.", + "Pour rotates to the poured pose and returns to its starting pose " + "inside a single 'pour' segment, so the end-of-segment rotation is " + "zero by construction; the peak is the achieved pour.", + "The rotation reference is captured at the pour segment start, so " + "the preceding PickUp rotation is not credited to Pour.", + "Planning time excludes a discarded warm-up compile.", + *summarize_video_recording(args, results, video_paths), + ], + ) + print(f"Markdown report saved: {report_path}") + return report_path + + +def main() -> None: + """Run the CLI entry point.""" + try: + run_all_benchmarks() + except RuntimeError as exc: + raise SystemExit(str(exc)) from exc + + +if __name__ == "__main__": + from scripts.tutorials.atomic_action.tutorial_utils import run_tutorial + + run_tutorial(main) + + +__all__ = [ + "add_benchmark_args", + "run_all_benchmarks", + "signed_rotation_about_axis_rad", +] diff --git a/scripts/benchmark/atomic_action/press_benchmark.py b/scripts/benchmark/atomic_action/press_benchmark.py new file mode 100644 index 000000000..a51a3dbdd --- /dev/null +++ b/scripts/benchmark/atomic_action/press_benchmark.py @@ -0,0 +1,508 @@ +# ---------------------------------------------------------------------------- +# Copyright (c) 2021-2026 DexForce Technology Co., Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ---------------------------------------------------------------------------- + +"""Benchmark Press atomic action on the microwave start button. + +Reports the four-level success ladder (planning_success, motion_valid, +execution_success, task_success). Task success requires the button's prismatic +joint to reach a configured fraction of its stroke during the commanded press +segment; the design protocol allows the button to rebound afterwards. +Run: embodichain benchmark atomic-action --action press +""" + +from __future__ import annotations + +import argparse +from dataclasses import dataclass +from pathlib import Path + +from scripts.benchmark.atomic_action.common import ( + CPU_MEMORY_BACKEND, + DEFAULT_REPLAY_STEPS_PER_WAYPOINT, + REPLAY_TRACKING_TOLERANCE_RAD, + SuccessLadder, + add_common_benchmark_args, + build_ladder_leaderboard, + build_video_output_path, + check_motion_valid, + ensure_repo_root, + ensure_torch, + format_float, + replay_and_track_joint, + replay_trajectory_with_recording, + reset_robot, + resolve_profile, + should_record_case, + summarize_video_recording, + timed_call, + warmup_planning, + write_markdown_report, +) + +BUTTON_JOINT_NAME = "start_button_press" +PRESS_SAMPLE_INTERVAL = 140 +HAND_INTERP_STEPS = 12 +APPROACH_DISTANCE = 0.12 +REPLAY_HOLD_STEPS = 60 +# The microwave button joint is a 6 mm prismatic stroke. Requiring 80% of the +# asset's own usable stroke keeps the gate asset-relative instead of hard-coding +# a millimetre figure that would not transfer to another button. +PRESS_MIN_STROKE_FRACTION = 0.8 + + +@dataclass(frozen=True) +class PressCase: + """Press commanded-travel benchmark case.""" + + name: str + press_distance_m: float + + +PRESS_CASES: dict[str, PressCase] = { + # Sensitivity case, kept out of the default set. Measured result: commanding + # 3 mm still drives the button to 5.66 mm of its 6 mm stroke, so on this + # asset the commanded travel does not control the achieved depth. The + # button joint has near-zero stiffness (1e-3), so it offers no resistance + # and bottoms out once contact is made. + "press_3mm": PressCase("press_3mm", 0.003), + "press_10mm": PressCase("press_10mm", 0.010), + "press_20mm": PressCase("press_20mm", 0.020), + "press_30mm": PressCase("press_30mm", 0.030), +} +DEFAULT_PRESS_CASES = ("press_10mm", "press_20mm", "press_30mm") +SMOKE_PRESS_CASE = "press_30mm" + + +def add_benchmark_args(parser: argparse.ArgumentParser) -> None: + """Add Press benchmark CLI arguments.""" + parser.add_argument( + "--press_cases", + nargs="+", + choices=(*PRESS_CASES.keys(), "all"), + default=list(DEFAULT_PRESS_CASES), + help="Commanded press-travel cases to benchmark. Use 'all' for every case.", + ) + add_common_benchmark_args(parser) + + +def _parse_args() -> argparse.Namespace: + """Parse command line arguments.""" + parser = argparse.ArgumentParser( + description="Benchmark Press over commanded button travel distances." + ) + add_benchmark_args(parser) + return parser.parse_args() + + +def _select_cases(case_names: list[str]) -> list[PressCase]: + """Resolve selected press case names.""" + if "all" in case_names: + return list(PRESS_CASES.values()) + return [PRESS_CASES[name] for name in case_names] + + +def _reset_scene(robot, microwave, initial_qpos, initial_button_qpos) -> None: + """Restore the robot and the microwave joints to their initial state.""" + reset_robot(robot, initial_qpos) + microwave.set_qpos(initial_button_qpos, target=False) + microwave.set_qpos(initial_button_qpos, target=True) + microwave.clear_dynamics() + + +def _build_invocation(atomic_engine, microwave, case: PressCase, physics_dt: float): + """Build the Press invocation and planning context for one case.""" + from embodichain.lab.sim.atomic_actions import MotionPolicy, PressGoal, PressOptions + from scripts.tutorials.atomic_action.press import create_button_semantics + + semantics, target_pose = create_button_semantics(microwave) + invocation = atomic_engine.make_invocation( + "press", + PressGoal(semantics, target_pose), + control_parts={"primary": {"motion": "arm", "grasp": "hand"}}, + motion_policy=MotionPolicy( + strategy="ik_interp", sample_count=PRESS_SAMPLE_INTERVAL + ), + skill_options=PressOptions( + hand_interp_steps=HAND_INTERP_STEPS, + approach_distance=APPROACH_DISTANCE, + press_distance=case.press_distance_m, + ), + ) + context = atomic_engine.initial_context(control_dt=physics_dt) + return invocation, context + + +def _press_measure_waypoint(compiled, traj) -> int: + """Return the last waypoint of the commanded press segment.""" + try: + return compiled.segment(0, "press").stop - 1 + except (KeyError, AttributeError, IndexError): + return int(traj.shape[1]) - 1 + + +def _run_case( + sim, + robot, + microwave, + atomic_engine, + initial_qpos, + initial_button_qpos, + button_joint_index: int, + button_stroke_m: float, + case: PressCase, + repeat: int, + args: argparse.Namespace, + recorded_count: int, +) -> dict[str, object]: + """Run one Press case through the full four-level success ladder.""" + ladder = SuccessLadder() + _reset_scene(robot, microwave, initial_qpos, initial_button_qpos) + sim.update(step=4) + + invocation, context = _build_invocation( + atomic_engine, microwave, case, sim.sim_config.physics_dt + ) + required_travel_m = PRESS_MIN_STROKE_FRACTION * button_stroke_m + + try: + elapsed, mem_delta, peak_gpu, result = timed_call( + lambda: atomic_engine.compile((invocation,), context) + ) + except Exception as exc: + print(f" planner exception: {type(exc).__name__}: {exc}") + ladder.fail("planning_success", "planner_exception") + return _case_result( + case, repeat, ladder, 0.0, None, None, required_travel_m, "" + ) + + ladder.planning_success = bool(result.plan_success.all().item()) + traj = result.trajectory.positions + if not ladder.planning_success: + ladder.fail("planning_success", "planner_reported_failure") + return _case_result( + case, + repeat, + ladder, + elapsed, + mem_delta, + None, + required_travel_m, + "", + peak_gpu, + ) + + motion_valid, motion_reason = check_motion_valid(traj, robot) + ladder.motion_valid = motion_valid + if not motion_valid: + ladder.fail("motion_valid", motion_reason) + return _case_result( + case, + repeat, + ladder, + elapsed, + mem_delta, + None, + required_travel_m, + "", + peak_gpu, + traj, + ) + + try: + trace = replay_and_track_joint( + sim=sim, + robot=robot, + traj=traj, + articulation=microwave, + joint_index=button_joint_index, + measure_waypoint=_press_measure_waypoint(result, traj), + steps_per_waypoint=DEFAULT_REPLAY_STEPS_PER_WAYPOINT, + hold_steps=REPLAY_HOLD_STEPS, + ) + ladder.execution_success = trace is not None + except Exception as exc: + print(f" replay exception: {type(exc).__name__}: {exc}") + trace = None + ladder.fail("execution_success", "controller_tracking_failure") + + if trace is not None and ( + trace.max_tracking_error_rad > REPLAY_TRACKING_TOLERANCE_RAD + ): + # The arm did not follow the plan, so the button reading describes the + # drive rather than Press. Do not score task success. + ladder.execution_success = False + ladder.fail("execution_success", "controller_tracking_failure") + elif ladder.execution_success and trace is not None: + # The button travels along +qpos; use the peak signed displacement so a + # button that reaches its stroke and rebounds still counts, exactly as + # the design protocol specifies for contact skills. + ladder.task_success = abs(trace.peak_signed_displacement) >= required_travel_m + if not ladder.task_success: + ladder.fail("task_success", "task_goal_miss") + elif not ladder.failure_stage: + ladder.fail("execution_success", "controller_tracking_failure") + + video_path = "" + if should_record_case(args, recorded_count, ladder.task_success): + _reset_scene(robot, microwave, initial_qpos, initial_button_qpos) + recorded = replay_trajectory_with_recording( + sim=sim, + robot=robot, + traj=traj, + args=args, + video_path=build_video_output_path( + args, "atomic_action_press", f"{case.name}_r{repeat}" + ), + ) + video_path = str(recorded) if recorded is not None else "" + + return _case_result( + case, + repeat, + ladder, + elapsed, + mem_delta, + trace, + required_travel_m, + video_path, + peak_gpu, + traj, + ) + + +def _case_result( + case: PressCase, + repeat: int, + ladder: SuccessLadder, + elapsed: float, + mem_delta: dict[str, float] | None, + trace, + required_travel_m: float, + video_path: str, + peak_gpu: float = 0.0, + traj=None, +) -> dict[str, object]: + """Assemble one Press case result row.""" + mem_delta = mem_delta or {"cpu_mb": 0.0, "gpu_mb": 0.0} + return { + "case_id": f"{case.name}:r{repeat}", + "press_case": case.name, + "repeat": repeat, + "ladder": ladder, + "commanded_press_m": case.press_distance_m, + "required_travel_m": required_travel_m, + "cost_time_ms": elapsed * 1000.0, + "cpu_delta_mb": mem_delta["cpu_mb"], + "gpu_delta_mb": mem_delta["gpu_mb"], + "peak_gpu_mb": peak_gpu, + "max_tracking_error_rad": trace.max_tracking_error_rad if trace else None, + "button_initial_m": trace.initial_position if trace else None, + "button_measured_disp_m": trace.measured_displacement if trace else None, + "button_peak_signed_m": trace.peak_signed_displacement if trace else None, + "button_settled_disp_m": trace.settled_displacement if trace else None, + "trajectory_waypoints": ( + int(traj.shape[1]) if traj is not None and traj.ndim >= 3 else 0 + ), + "success": ladder.task_success, + "video_path": video_path, + } + + +def _build_rows(results: list[dict[str, object]]): + """Convert case results into report row dicts.""" + perf_rows: list[dict[str, object]] = [] + metric_rows: list[dict[str, object]] = [] + for result in results: + perf_rows.append( + { + "sample_size": 1, + "impl": "press", + "case_id": result["case_id"], + "press_case": result["press_case"], + "repeat": result["repeat"], + "cost_time_ms": format_float(result["cost_time_ms"], precision=2), + "cpu_delta_mb": format_float(result["cpu_delta_mb"], precision=1), + "gpu_delta_mb": format_float(result["gpu_delta_mb"], precision=1), + "peak_gpu_mb": format_float(result["peak_gpu_mb"], precision=1), + "trajectory_waypoints": result["trajectory_waypoints"], + } + ) + ladder: SuccessLadder = result["ladder"] # type: ignore[assignment] + metric_rows.append( + { + "sample_size": 1, + "impl": "press", + "case_id": result["case_id"], + "press_case": result["press_case"], + "success_rate": f"{float(ladder.task_success):.6f}", + **ladder.as_row_fields(), + "commanded_press_m": format_float(result["commanded_press_m"], 4), + "required_travel_m": format_float(result["required_travel_m"], 4), + "max_tracking_error_rad": format_float( + result["max_tracking_error_rad"], 4 + ), + "button_initial_m": format_float(result["button_initial_m"], 5), + "button_measured_disp_m": format_float( + result["button_measured_disp_m"], 5 + ), + "button_peak_signed_m": format_float(result["button_peak_signed_m"], 5), + "button_settled_disp_m": format_float( + result["button_settled_disp_m"], 5 + ), + } + ) + return perf_rows, metric_rows + + +def run_all_benchmarks(args: argparse.Namespace | None = None) -> Path: + """Run Press benchmark and write a markdown report.""" + args = _parse_args() if args is None else args + if args.repeat < 1: + raise ValueError("--repeat must be at least 1.") + profile = resolve_profile(args) + ensure_repo_root() + ensure_torch() + from embodichain.lab.sim.atomic_actions import ( + AtomicActionEngine, + ControlPartCommandProfile, + ) + from scripts.tutorials.atomic_action.press import create_microwave + from scripts.tutorials.atomic_action.tutorial_utils import ( + add_ur5_gripper_robot, + create_toppra_motion_generator, + get_hand_open_close_qpos, + initialize_benchmark_simulation, + ) + + cases = _select_cases(args.press_cases) + repeat = 1 if profile == "smoke" else args.repeat + if profile == "smoke": + cases = [PRESS_CASES[SMOKE_PRESS_CASE]] + + print("=" * 60) + print("Press Atomic Action Benchmark") + print("=" * 60) + print( + f"Coverage: profile={profile}, {len(cases)} press case(s) x {repeat} repeat(s)" + ) + + sim = initialize_benchmark_simulation(args) + robot = add_ur5_gripper_robot( + sim, init_qpos=[0.0, -1.57, 1.57, -3.14, -1.57, 0.0, 0.0, 0.0] + ) + microwave = create_microwave(sim) + sim.prepare() + sim.update(step=10) + initial_qpos = robot.get_qpos().clone() + initial_button_qpos = microwave.get_qpos(target=False).clone() + hand_open, hand_close = get_hand_open_close_qpos(robot, close_qpos=0.040) + + button_joint_index = microwave.joint_names.index(BUTTON_JOINT_NAME) + button_limits = microwave.get_qpos_limits()[0, button_joint_index] + button_stroke_m = float(button_limits[1] - button_limits[0]) + print( + f"Button joint {BUTTON_JOINT_NAME!r}: stroke={button_stroke_m * 1000.0:.2f} mm, " + f"required travel={PRESS_MIN_STROKE_FRACTION * button_stroke_m * 1000.0:.2f} mm" + ) + + atomic_engine = AtomicActionEngine( + motion_generator=create_toppra_motion_generator(robot), + control_profiles={ + "hand": ControlPartCommandProfile.joint_positions( + open=hand_open, + grasp=hand_close, + ) + }, + ) + + warmup_invocation, warmup_context = _build_invocation( + atomic_engine, microwave, cases[0], sim.sim_config.physics_dt + ) + print("Warm-up: running one discarded compile to exclude first-call cost...") + warmup_planning(lambda: atomic_engine.compile((warmup_invocation,), warmup_context)) + _reset_scene(robot, microwave, initial_qpos, initial_button_qpos) + + results: list[dict[str, object]] = [] + video_paths: list[str] = [] + print("\n=== Press Commanded-Travel Sweep ===") + for case in cases: + for repeat_index in range(repeat): + result = _run_case( + sim, + robot, + microwave, + atomic_engine, + initial_qpos, + initial_button_qpos, + button_joint_index, + button_stroke_m, + case, + repeat_index, + args, + len(video_paths), + ) + results.append(result) + if result["video_path"]: + video_paths.append(str(result["video_path"])) + ladder: SuccessLadder = result["ladder"] # type: ignore[assignment] + print( + f" {result['case_id']:<22} " + f"time={result['cost_time_ms']:>9.2f} ms | " + f"plan={ladder.planning_success} valid={ladder.motion_valid} " + f"exec={ladder.execution_success} task={ladder.task_success} " + f"peak={format_float(result['button_peak_signed_m'], 5)} " + f"[{ladder.failure_reason or 'ok'}]" + ) + + perf_rows, metric_rows = _build_rows(results) + leaderboard_rows = build_ladder_leaderboard("press", results) + report_path = write_markdown_report( + benchmark_name="atomic_action_press", + perf_rows=perf_rows, + metric_rows=metric_rows, + leaderboard_rows=leaderboard_rows, + notes=[ + f"Profile: {profile}", + f"CPU memory backend: {CPU_MEMORY_BACKEND}", + f"Button joint: {BUTTON_JOINT_NAME}, usable stroke " + f"{button_stroke_m * 1000.0:.2f} mm.", + "task_success requires the peak signed button displacement during " + f"replay to reach {PRESS_MIN_STROKE_FRACTION:.0%} of that stroke " + f"({PRESS_MIN_STROKE_FRACTION * button_stroke_m * 1000.0:.2f} mm). " + "Rebound after the press segment is allowed.", + "Planning time excludes a discarded warm-up compile.", + *summarize_video_recording(args, results, video_paths), + ], + ) + print(f"Markdown report saved: {report_path}") + return report_path + + +def main() -> None: + """Run the CLI entry point.""" + try: + run_all_benchmarks() + except RuntimeError as exc: + raise SystemExit(str(exc)) from exc + + +if __name__ == "__main__": + from scripts.tutorials.atomic_action.tutorial_utils import run_tutorial + + run_tutorial(main) + + +__all__ = ["add_benchmark_args", "run_all_benchmarks"] diff --git a/scripts/benchmark/atomic_action/run_benchmark.py b/scripts/benchmark/atomic_action/run_benchmark.py index d50107756..482c798b1 100644 --- a/scripts/benchmark/atomic_action/run_benchmark.py +++ b/scripts/benchmark/atomic_action/run_benchmark.py @@ -43,6 +43,13 @@ "pick_up": "scripts.benchmark.atomic_action.pickup_benchmark", "move_held_object": "scripts.benchmark.atomic_action.move_held_object_benchmark", "place": "scripts.benchmark.atomic_action.place_benchmark", + "open_door": "scripts.benchmark.atomic_action.open_door_benchmark", + "press": "scripts.benchmark.atomic_action.press_benchmark", + "slide": "scripts.benchmark.atomic_action.slide_benchmark", + "twist": "scripts.benchmark.atomic_action.twist_benchmark", + "axis_align": "scripts.benchmark.atomic_action.axis_align_benchmark", + "pour": "scripts.benchmark.atomic_action.pour_benchmark", + "hand_over": "scripts.benchmark.atomic_action.hand_over_benchmark", } DEFAULT_ACTIONS = tuple(ACTION_MODULES.keys()) MESH_OBJECT_ACTIONS = {"pick_up", "move_held_object", "place"} diff --git a/scripts/benchmark/atomic_action/slide_benchmark.py b/scripts/benchmark/atomic_action/slide_benchmark.py new file mode 100644 index 000000000..81c65f440 --- /dev/null +++ b/scripts/benchmark/atomic_action/slide_benchmark.py @@ -0,0 +1,401 @@ +# ---------------------------------------------------------------------------- +# Copyright (c) 2021-2026 DexForce Technology Co., Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ---------------------------------------------------------------------------- + +"""Benchmark Slide atomic action on the drawer prismatic joint. + +Reports the four-level success ladder (planning_success, motion_valid, +execution_success, task_success). Task success requires the drawer joint to +reach the commanded translation distance during the commanded pull segment. +Run: embodichain benchmark atomic-action --action slide +""" + +from __future__ import annotations + +import argparse +from dataclasses import dataclass +from pathlib import Path + +from scripts.benchmark.atomic_action.common import ( + CPU_MEMORY_BACKEND, + SuccessLadder, + add_common_benchmark_args, + add_grasp_benchmark_args, + build_ladder_leaderboard, + build_video_output_path, + ensure_repo_root, + ensure_torch, + format_float, + replay_trajectory_with_recording, + reset_robot, + resolve_profile, + run_articulated_contact_case, + should_record_case, + summarize_video_recording, + warmup_planning, + write_markdown_report, +) + +DRAWER_JOINT_NAME = "cabinet_to_drawer" +TRAJECTORY_SAMPLE_COUNT = 140 +HAND_INTERP_STEPS = 12 +APPROACH_DISTANCE = 0.10 +REPLAY_HOLD_STEPS = 60 +# Commanded-versus-achieved drawer travel tolerance. Calibrated from the +# measured pull error on this asset; see BENCHMARK_STANDARD.md. +SLIDE_TOLERANCE_M = 0.03 + + +@dataclass(frozen=True) +class SlideCase: + """Slide commanded-travel benchmark case.""" + + name: str + translation_distance_m: float + + +SLIDE_CASES: dict[str, SlideCase] = { + "pull_100mm": SlideCase("pull_100mm", 0.10), + "pull_180mm": SlideCase("pull_180mm", 0.18), + "pull_250mm": SlideCase("pull_250mm", 0.25), +} +DEFAULT_SLIDE_CASES = tuple(SLIDE_CASES.keys()) +SMOKE_SLIDE_CASE = "pull_180mm" + + +def add_benchmark_args(parser: argparse.ArgumentParser) -> None: + """Add Slide benchmark CLI arguments.""" + parser.add_argument( + "--slide_cases", + nargs="+", + choices=(*SLIDE_CASES.keys(), "all"), + default=list(DEFAULT_SLIDE_CASES), + help="Commanded drawer-travel cases to benchmark. Use 'all' for every case.", + ) + add_grasp_benchmark_args(parser) + add_common_benchmark_args(parser) + + +def _parse_args() -> argparse.Namespace: + """Parse command line arguments.""" + parser = argparse.ArgumentParser( + description="Benchmark Slide over commanded drawer travel distances." + ) + add_benchmark_args(parser) + return parser.parse_args() + + +def _select_cases(case_names: list[str]) -> list[SlideCase]: + """Resolve selected slide case names.""" + if "all" in case_names: + return list(SLIDE_CASES.values()) + return [SLIDE_CASES[name] for name in case_names] + + +def _reset_scene(robot, drawer, initial_qpos, initial_drawer_qpos) -> None: + """Restore the robot and the drawer to their initial closed state.""" + reset_robot(robot, initial_qpos) + drawer.set_qpos(initial_drawer_qpos, target=False) + drawer.set_qpos(initial_drawer_qpos, target=True) + drawer.clear_dynamics() + + +def _build_invocation(atomic_engine, drawer, semantics, case: SlideCase, physics_dt): + """Build the Slide pull invocation and planning context for one case.""" + from embodichain.lab.sim.atomic_actions import MotionPolicy, SlideGoal, SlideOptions + from scripts.tutorials.atomic_action.slide import HANDLE_LINK_NAME + + handle_pose = drawer.get_link_pose(HANDLE_LINK_NAME, to_matrix=True) + invocation = atomic_engine.make_invocation( + "slide", + SlideGoal(semantics, handle_pose), + control_parts={"primary": {"motion": "arm", "grasp": "hand"}}, + motion_policy=MotionPolicy( + strategy="ik_interp", sample_count=TRAJECTORY_SAMPLE_COUNT + ), + skill_options=SlideOptions( + direction="pull", + hand_interp_steps=HAND_INTERP_STEPS, + approach_distance=APPROACH_DISTANCE, + translation_distance=case.translation_distance_m, + ), + ) + return invocation, atomic_engine.initial_context(control_dt=physics_dt) + + +def _build_rows(results: list[dict[str, object]]): + """Convert case results into report row dicts.""" + perf_rows: list[dict[str, object]] = [] + metric_rows: list[dict[str, object]] = [] + for result in results: + perf_rows.append( + { + "sample_size": 1, + "impl": "slide", + "case_id": result["case_id"], + "slide_case": result["slide_case"], + "repeat": result["repeat"], + "cost_time_ms": format_float(result["cost_time_ms"], precision=2), + "cpu_delta_mb": format_float(result["cpu_delta_mb"], precision=1), + "gpu_delta_mb": format_float(result["gpu_delta_mb"], precision=1), + "peak_gpu_mb": format_float(result["peak_gpu_mb"], precision=1), + "trajectory_waypoints": result["trajectory_waypoints"], + } + ) + ladder: SuccessLadder = result["ladder"] # type: ignore[assignment] + metric_rows.append( + { + "sample_size": 1, + "impl": "slide", + "case_id": result["case_id"], + "slide_case": result["slide_case"], + "success_rate": f"{float(ladder.task_success):.6f}", + **ladder.as_row_fields(), + "commanded_travel_m": format_float(result["commanded_travel_m"], 4), + "max_tracking_error_rad": format_float( + result["max_tracking_error_rad"], 4 + ), + "drawer_initial_m": format_float(result["drawer_initial_m"], 4), + "drawer_measured_disp_m": format_float( + result["drawer_measured_disp_m"], 4 + ), + "drawer_peak_signed_m": format_float(result["drawer_peak_signed_m"], 4), + "drawer_settled_disp_m": format_float( + result["drawer_settled_disp_m"], 4 + ), + "travel_error_m": format_float(result["travel_error_m"], 4), + } + ) + return perf_rows, metric_rows + + +def run_all_benchmarks(args: argparse.Namespace | None = None) -> Path: + """Run Slide benchmark and write a markdown report.""" + args = _parse_args() if args is None else args + if args.repeat < 1: + raise ValueError("--repeat must be at least 1.") + profile = resolve_profile(args) + ensure_repo_root() + ensure_torch() + from embodichain.lab.sim.atomic_actions import ( + AtomicActionEngine, + ControlPartCommandProfile, + ) + from scripts.tutorials.atomic_action.slide import ( + create_drawer, + create_drawer_semantics, + ) + from scripts.tutorials.atomic_action.tutorial_utils import ( + add_ur5_gripper_robot, + create_parallel_jaw_grasp_pose_generator, + create_toppra_motion_generator, + get_hand_open_close_qpos, + initialize_benchmark_simulation, + ) + + cases = _select_cases(args.slide_cases) + repeat = 1 if profile == "smoke" else args.repeat + if profile == "smoke": + cases = [SLIDE_CASES[SMOKE_SLIDE_CASE]] + + print("=" * 60) + print("Slide Atomic Action Benchmark") + print("=" * 60) + print( + f"Coverage: profile={profile}, {len(cases)} slide case(s) x {repeat} repeat(s)" + ) + + sim = initialize_benchmark_simulation(args) + robot = add_ur5_gripper_robot( + sim, init_qpos=[0.0, -1.57, 1.57, -3.14, -1.57, 0.0, 0.0, 0.0], tcp_z=0.15 + ) + drawer = create_drawer(sim) + sim.prepare() + sim.update(step=10) + initial_qpos = robot.get_qpos().clone() + initial_drawer_qpos = drawer.get_qpos(target=False).clone() + hand_open, hand_close = get_hand_open_close_qpos(robot) + + drawer_joint_index = drawer.joint_names.index(DRAWER_JOINT_NAME) + drawer_limits = drawer.get_qpos_limits()[0, drawer_joint_index] + drawer_stroke_m = float(drawer_limits[1] - drawer_limits[0]) + print( + f"Drawer joint {DRAWER_JOINT_NAME!r}: stroke={drawer_stroke_m:.3f} m, " + f"tolerance={SLIDE_TOLERANCE_M:.3f} m" + ) + + n_sample = 1000 if profile == "smoke" else args.n_sample + atomic_engine = AtomicActionEngine( + motion_generator=create_toppra_motion_generator(robot), + control_profiles={ + "hand": ControlPartCommandProfile.joint_positions( + open=hand_open, grasp=hand_close + ) + }, + grasp_pose_generators={ + "hand": create_parallel_jaw_grasp_pose_generator( + n_sample=n_sample, force_refresh=args.force_reannotate + ) + }, + ) + semantics = create_drawer_semantics(drawer) + + print("Warm-up: running one discarded compile to exclude first-call cost...") + warmup_planning( + lambda: atomic_engine.compile( + ( + _build_invocation( + atomic_engine, + drawer, + semantics, + cases[0], + sim.sim_config.physics_dt, + )[0], + ), + atomic_engine.initial_context(control_dt=sim.sim_config.physics_dt), + ) + ) + _reset_scene(robot, drawer, initial_qpos, initial_drawer_qpos) + + results: list[dict[str, object]] = [] + video_paths: list[str] = [] + print("\n=== Slide Commanded-Travel Sweep ===") + for case in cases: + for repeat_index in range(repeat): + _reset_scene(robot, drawer, initial_qpos, initial_drawer_qpos) + sim.update(step=4) + + def evaluate(trace, case=case): + error = abs( + abs(trace.measured_displacement) - case.translation_distance_m + ) + return (error <= SLIDE_TOLERANCE_M, "task_goal_miss") + + outcome = run_articulated_contact_case( + sim=sim, + robot=robot, + articulation=drawer, + atomic_engine=atomic_engine, + build_invocation=lambda case=case: _build_invocation( + atomic_engine, drawer, semantics, case, sim.sim_config.physics_dt + ), + joint_index=drawer_joint_index, + actuation_segment="pull", + evaluate=evaluate, + hold_steps=REPLAY_HOLD_STEPS, + ) + trace = outcome.trace + travel_error = None + if trace is not None: + travel_error = abs( + abs(trace.measured_displacement) - case.translation_distance_m + ) + + video_path = "" + if should_record_case(args, len(video_paths), outcome.ladder.task_success): + _reset_scene(robot, drawer, initial_qpos, initial_drawer_qpos) + recorded = replay_trajectory_with_recording( + sim=sim, + robot=robot, + traj=outcome.trajectory, + args=args, + video_path=build_video_output_path( + args, "atomic_action_slide", f"{case.name}_r{repeat_index}" + ), + ) + video_path = str(recorded) if recorded is not None else "" + video_paths.append(video_path) + + result = { + "case_id": f"{case.name}:r{repeat_index}", + "slide_case": case.name, + "repeat": repeat_index, + "ladder": outcome.ladder, + "commanded_travel_m": case.translation_distance_m, + "cost_time_ms": outcome.elapsed_s * 1000.0, + "cpu_delta_mb": outcome.cpu_delta_mb, + "gpu_delta_mb": outcome.gpu_delta_mb, + "peak_gpu_mb": outcome.peak_gpu_mb, + "max_tracking_error_rad": ( + trace.max_tracking_error_rad if trace else None + ), + "drawer_initial_m": trace.initial_position if trace else None, + "drawer_measured_disp_m": ( + trace.measured_displacement if trace else None + ), + "drawer_peak_signed_m": ( + trace.peak_signed_displacement if trace else None + ), + "drawer_settled_disp_m": trace.settled_displacement if trace else None, + "travel_error_m": travel_error, + "trajectory_waypoints": outcome.trajectory_waypoints, + "success": outcome.ladder.task_success, + "video_path": video_path, + } + results.append(result) + ladder = outcome.ladder + print( + f" {result['case_id']:<22} " + f"time={result['cost_time_ms']:>9.2f} ms | " + f"plan={ladder.planning_success} valid={ladder.motion_valid} " + f"exec={ladder.execution_success} task={ladder.task_success} " + f"travel={format_float(result['drawer_measured_disp_m'], 4)} " + f"err={format_float(travel_error, 4)} " + f"[{ladder.failure_reason or 'ok'}]" + ) + + perf_rows, metric_rows = _build_rows(results) + leaderboard_rows = build_ladder_leaderboard("slide", results) + report_path = write_markdown_report( + benchmark_name="atomic_action_slide", + perf_rows=perf_rows, + metric_rows=metric_rows, + leaderboard_rows=leaderboard_rows, + notes=[ + f"Profile: {profile}", + f"CPU memory backend: {CPU_MEMORY_BACKEND}", + f"Grasp samples: {n_sample}", + f"Drawer joint: {DRAWER_JOINT_NAME}, usable stroke " + f"{drawer_stroke_m:.3f} m.", + "task_success requires the replayed drawer travel at the end of the " + "commanded 'pull' segment to match the commanded translation within " + f"{SLIDE_TOLERANCE_M:.3f} m.", + "drawer_settled_disp_m is the resting travel after release and " + "retract. The drawer joint is undriven (drive_type='none'), so that " + "value is diagnostic, not a success gate.", + "Planning time excludes a discarded warm-up compile and includes " + "handle grasp-pose generation.", + *summarize_video_recording(args, results, video_paths), + ], + ) + print(f"Markdown report saved: {report_path}") + return report_path + + +def main() -> None: + """Run the CLI entry point.""" + try: + run_all_benchmarks() + except RuntimeError as exc: + raise SystemExit(str(exc)) from exc + + +if __name__ == "__main__": + from scripts.tutorials.atomic_action.tutorial_utils import run_tutorial + + run_tutorial(main) + + +__all__ = ["add_benchmark_args", "run_all_benchmarks"] diff --git a/scripts/benchmark/atomic_action/twist_benchmark.py b/scripts/benchmark/atomic_action/twist_benchmark.py new file mode 100644 index 000000000..d268d96f6 --- /dev/null +++ b/scripts/benchmark/atomic_action/twist_benchmark.py @@ -0,0 +1,372 @@ +# ---------------------------------------------------------------------------- +# Copyright (c) 2021-2026 DexForce Technology Co., Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ---------------------------------------------------------------------------- + +"""Benchmark Twist atomic action on the microwave power knob. + +Reports the four-level success ladder (planning_success, motion_valid, +execution_success, task_success). Task success requires the knob's revolute +joint to reach the commanded twist angle during the commanded twist segment. +Run: embodichain benchmark atomic-action --action twist +""" + +from __future__ import annotations + +import argparse +import math +from dataclasses import dataclass +from pathlib import Path + +from scripts.benchmark.atomic_action.common import ( + CPU_MEMORY_BACKEND, + SuccessLadder, + add_common_benchmark_args, + build_ladder_leaderboard, + build_video_output_path, + ensure_repo_root, + ensure_torch, + format_float, + replay_trajectory_with_recording, + reset_robot, + resolve_profile, + run_articulated_contact_case, + should_record_case, + summarize_video_recording, + warmup_planning, + write_markdown_report, +) + +KNOB_JOINT_NAME = "power_knob_rotation" +TWIST_SAMPLE_INTERVAL = 140 +HAND_INTERP_STEPS = 12 +PRE_GRASP_DISTANCE = 0.12 +REPLAY_HOLD_STEPS = 60 +# Commanded-versus-achieved knob rotation tolerance. Calibrated from the +# measured twist error on this asset; see BENCHMARK_STANDARD.md. +TWIST_TOLERANCE_RAD = 0.15 + + +@dataclass(frozen=True) +class TwistCase: + """Twist commanded-rotation benchmark case.""" + + name: str + twist_angle_rad: float + + +TWIST_CASES: dict[str, TwistCase] = { + "twist_ccw_30": TwistCase("twist_ccw_30", math.radians(-30.0)), + "twist_ccw_45": TwistCase("twist_ccw_45", math.radians(-45.0)), + "twist_ccw_90": TwistCase("twist_ccw_90", math.radians(-90.0)), +} +DEFAULT_TWIST_CASES = tuple(TWIST_CASES.keys()) +SMOKE_TWIST_CASE = "twist_ccw_45" + + +def add_benchmark_args(parser: argparse.ArgumentParser) -> None: + """Add Twist benchmark CLI arguments.""" + parser.add_argument( + "--twist_cases", + nargs="+", + choices=(*TWIST_CASES.keys(), "all"), + default=list(DEFAULT_TWIST_CASES), + help="Commanded knob-rotation cases to benchmark. Use 'all' for every case.", + ) + add_common_benchmark_args(parser) + + +def _parse_args() -> argparse.Namespace: + """Parse command line arguments.""" + parser = argparse.ArgumentParser( + description="Benchmark Twist over commanded knob rotation angles." + ) + add_benchmark_args(parser) + return parser.parse_args() + + +def _select_cases(case_names: list[str]) -> list[TwistCase]: + """Resolve selected twist case names.""" + if "all" in case_names: + return list(TWIST_CASES.values()) + return [TWIST_CASES[name] for name in case_names] + + +def _reset_scene(robot, microwave, initial_qpos, initial_knob_qpos) -> None: + """Restore the robot and the microwave joints to their initial state.""" + reset_robot(robot, initial_qpos) + microwave.set_qpos(initial_knob_qpos, target=False) + microwave.set_qpos(initial_knob_qpos, target=True) + microwave.clear_dynamics() + + +def _build_invocation(atomic_engine, microwave, case: TwistCase, physics_dt: float): + """Build the Twist invocation and planning context for one case.""" + from embodichain.lab.sim.atomic_actions import MotionPolicy, TwistGoal, TwistOptions + from scripts.tutorials.atomic_action.twist import create_knob_semantics + + semantics, target_pose = create_knob_semantics(microwave) + invocation = atomic_engine.make_invocation( + "twist", + TwistGoal(semantics, target_pose), + control_parts={"primary": {"motion": "arm", "grasp": "hand"}}, + motion_policy=MotionPolicy( + strategy="motion_gen", sample_count=TWIST_SAMPLE_INTERVAL + ), + skill_options=TwistOptions( + hand_interp_steps=HAND_INTERP_STEPS, + pre_grasp_distance=PRE_GRASP_DISTANCE, + twist_angle=case.twist_angle_rad, + ), + ) + return invocation, atomic_engine.initial_context(control_dt=physics_dt) + + +def _build_rows(results: list[dict[str, object]]): + """Convert case results into report row dicts.""" + perf_rows: list[dict[str, object]] = [] + metric_rows: list[dict[str, object]] = [] + for result in results: + perf_rows.append( + { + "sample_size": 1, + "impl": "twist", + "case_id": result["case_id"], + "twist_case": result["twist_case"], + "repeat": result["repeat"], + "cost_time_ms": format_float(result["cost_time_ms"], precision=2), + "cpu_delta_mb": format_float(result["cpu_delta_mb"], precision=1), + "gpu_delta_mb": format_float(result["gpu_delta_mb"], precision=1), + "peak_gpu_mb": format_float(result["peak_gpu_mb"], precision=1), + "trajectory_waypoints": result["trajectory_waypoints"], + } + ) + ladder: SuccessLadder = result["ladder"] # type: ignore[assignment] + metric_rows.append( + { + "sample_size": 1, + "impl": "twist", + "case_id": result["case_id"], + "twist_case": result["twist_case"], + "success_rate": f"{float(ladder.task_success):.6f}", + **ladder.as_row_fields(), + "commanded_angle_rad": format_float(result["commanded_angle_rad"], 4), + "max_tracking_error_rad": format_float( + result["max_tracking_error_rad"], 4 + ), + "knob_initial_rad": format_float(result["knob_initial_rad"], 4), + "knob_measured_disp_rad": format_float( + result["knob_measured_disp_rad"], 4 + ), + "knob_peak_signed_rad": format_float(result["knob_peak_signed_rad"], 4), + "knob_settled_disp_rad": format_float( + result["knob_settled_disp_rad"], 4 + ), + "angle_error_rad": format_float(result["angle_error_rad"], 4), + } + ) + return perf_rows, metric_rows + + +def run_all_benchmarks(args: argparse.Namespace | None = None) -> Path: + """Run Twist benchmark and write a markdown report.""" + args = _parse_args() if args is None else args + if args.repeat < 1: + raise ValueError("--repeat must be at least 1.") + profile = resolve_profile(args) + ensure_repo_root() + ensure_torch() + from embodichain.lab.sim.atomic_actions import ( + AtomicActionEngine, + ControlPartCommandProfile, + ) + from scripts.tutorials.atomic_action.tutorial_utils import ( + add_ur5_gripper_robot, + create_toppra_motion_generator, + get_hand_open_close_qpos, + initialize_benchmark_simulation, + ) + from scripts.tutorials.atomic_action.twist import create_microwave + + cases = _select_cases(args.twist_cases) + repeat = 1 if profile == "smoke" else args.repeat + if profile == "smoke": + cases = [TWIST_CASES[SMOKE_TWIST_CASE]] + + print("=" * 60) + print("Twist Atomic Action Benchmark") + print("=" * 60) + print( + f"Coverage: profile={profile}, {len(cases)} twist case(s) x {repeat} repeat(s)" + ) + + sim = initialize_benchmark_simulation(args) + robot = add_ur5_gripper_robot( + sim, init_qpos=[0.0, -1.57, 1.57, -3.14, -1.57, 0.0, 0.0, 0.0] + ) + microwave = create_microwave(sim) + sim.prepare() + sim.update(step=10) + initial_qpos = robot.get_qpos().clone() + initial_knob_qpos = microwave.get_qpos(target=False).clone() + hand_open, hand_close = get_hand_open_close_qpos(robot) + + knob_joint_index = microwave.joint_names.index(KNOB_JOINT_NAME) + print( + f"Knob joint {KNOB_JOINT_NAME!r} (link cap_1), " + f"tolerance={TWIST_TOLERANCE_RAD:.3f} rad" + ) + + atomic_engine = AtomicActionEngine( + motion_generator=create_toppra_motion_generator(robot), + control_profiles={ + "hand": ControlPartCommandProfile.joint_positions( + open=hand_open, grasp=hand_close + ) + }, + ) + + print("Warm-up: running one discarded compile to exclude first-call cost...") + warmup_invocation, warmup_context = _build_invocation( + atomic_engine, microwave, cases[0], sim.sim_config.physics_dt + ) + warmup_planning(lambda: atomic_engine.compile((warmup_invocation,), warmup_context)) + _reset_scene(robot, microwave, initial_qpos, initial_knob_qpos) + + results: list[dict[str, object]] = [] + video_paths: list[str] = [] + print("\n=== Twist Commanded-Rotation Sweep ===") + for case in cases: + for repeat_index in range(repeat): + _reset_scene(robot, microwave, initial_qpos, initial_knob_qpos) + sim.update(step=4) + + def evaluate(trace, case=case): + error = abs( + abs(trace.measured_displacement) - abs(case.twist_angle_rad) + ) + return (error <= TWIST_TOLERANCE_RAD, "task_goal_miss") + + outcome = run_articulated_contact_case( + sim=sim, + robot=robot, + articulation=microwave, + atomic_engine=atomic_engine, + build_invocation=lambda case=case: _build_invocation( + atomic_engine, microwave, case, sim.sim_config.physics_dt + ), + joint_index=knob_joint_index, + actuation_segment="twist", + evaluate=evaluate, + hold_steps=REPLAY_HOLD_STEPS, + ) + trace = outcome.trace + angle_error = None + if trace is not None: + angle_error = abs( + abs(trace.measured_displacement) - abs(case.twist_angle_rad) + ) + + video_path = "" + if should_record_case(args, len(video_paths), outcome.ladder.task_success): + _reset_scene(robot, microwave, initial_qpos, initial_knob_qpos) + recorded = replay_trajectory_with_recording( + sim=sim, + robot=robot, + traj=outcome.trajectory, + args=args, + video_path=build_video_output_path( + args, "atomic_action_twist", f"{case.name}_r{repeat_index}" + ), + ) + video_path = str(recorded) if recorded is not None else "" + video_paths.append(video_path) + + result = { + "case_id": f"{case.name}:r{repeat_index}", + "twist_case": case.name, + "repeat": repeat_index, + "ladder": outcome.ladder, + "commanded_angle_rad": case.twist_angle_rad, + "cost_time_ms": outcome.elapsed_s * 1000.0, + "cpu_delta_mb": outcome.cpu_delta_mb, + "gpu_delta_mb": outcome.gpu_delta_mb, + "peak_gpu_mb": outcome.peak_gpu_mb, + "max_tracking_error_rad": ( + trace.max_tracking_error_rad if trace else None + ), + "knob_initial_rad": trace.initial_position if trace else None, + "knob_measured_disp_rad": ( + trace.measured_displacement if trace else None + ), + "knob_peak_signed_rad": ( + trace.peak_signed_displacement if trace else None + ), + "knob_settled_disp_rad": trace.settled_displacement if trace else None, + "angle_error_rad": angle_error, + "trajectory_waypoints": outcome.trajectory_waypoints, + "success": outcome.ladder.task_success, + "video_path": video_path, + } + results.append(result) + ladder = outcome.ladder + print( + f" {result['case_id']:<22} " + f"time={result['cost_time_ms']:>9.2f} ms | " + f"plan={ladder.planning_success} valid={ladder.motion_valid} " + f"exec={ladder.execution_success} task={ladder.task_success} " + f"rot={format_float(result['knob_measured_disp_rad'], 4)} " + f"err={format_float(angle_error, 4)} " + f"[{ladder.failure_reason or 'ok'}]" + ) + + perf_rows, metric_rows = _build_rows(results) + leaderboard_rows = build_ladder_leaderboard("twist", results) + report_path = write_markdown_report( + benchmark_name="atomic_action_twist", + perf_rows=perf_rows, + metric_rows=metric_rows, + leaderboard_rows=leaderboard_rows, + notes=[ + f"Profile: {profile}", + f"CPU memory backend: {CPU_MEMORY_BACKEND}", + f"Knob joint: {KNOB_JOINT_NAME} (target link cap_1).", + "task_success requires the replayed knob rotation at the end of the " + "commanded 'twist' segment to match the commanded angle within " + f"{TWIST_TOLERANCE_RAD:.3f} rad.", + "knob_settled_disp_rad is the resting rotation after release and " + "retract; it is diagnostic, not a success gate.", + "Planning time excludes a discarded warm-up compile.", + *summarize_video_recording(args, results, video_paths), + ], + ) + print(f"Markdown report saved: {report_path}") + return report_path + + +def main() -> None: + """Run the CLI entry point.""" + try: + run_all_benchmarks() + except RuntimeError as exc: + raise SystemExit(str(exc)) from exc + + +if __name__ == "__main__": + from scripts.tutorials.atomic_action.tutorial_utils import run_tutorial + + run_tutorial(main) + + +__all__ = ["add_benchmark_args", "run_all_benchmarks"] From cb581e883e99e66db8944df705731d23290da0bf Mon Sep 17 00:00:00 2001 From: Xinyi YUAN Date: Tue, 22 Sep 2026 15:16:58 +0900 Subject: [PATCH 02/10] feat(bench): measure the twelve skills the way the standard defines The suite shipped its own four-level ladder (planning_success, motion_valid, execution_success, task_success), legacy tolerances, and a tracking gate. The standard on the base branch replaced all three, so the benchmarks measured something the document no longer describes. Measurement layer: - StageLadder replaces SuccessLadder. Stages come from SKILL_STAGES, which mirrors section 1 for all fifteen skills; a stage is reached only when every earlier one passed, and recording past the first failure is ignored. - build_stage_leaderboard reports passed(i)/reached(i) per stage plus the overall success rate, with N/A for a stage no case reached. - Plan validity and replay tracking are diagnostics: the tracking gate and its REPLAY_TRACKING_TOLERANCE_RAD are gone, and the taxonomy is the nine task-level reasons, not seventeen. - PRIMITIVE_*/TASK_* tolerances replace PHYSICAL_PLACE_XY_TOLERANCE_M (0.10) and PHYSICAL_MOVE_HELD_OBJECT_XYZ_TOLERANCE_M (0.12). - dropped_below_support applies the cross-cutting drop rule against the lowest point of a replay; hand_is_released measures a release against the skill's own open and closed commands rather than a per-asset threshold. - replay_and_track_channels samples several scalars in one replay, so a skill scored on one signal can report another. replay_and_track_scalar is now a wrapper over it, and the physical-validation replay runs at the calibrated 16 steps per waypoint instead of 4. - A missing actuation segment fails the case as invalid_case instead of silently scoring the trajectory's last waypoint, which is the rebound the helper exists to avoid. Per skill: - MoveJoints and MoveEndEffector are measured on the robot after a physical replay and a terminal settle, not from traj[:, -1], which reported 0.000000 rad and 0.00002 m and described the solver. - PickUp, Place, AxisAlign, MoveHeldObject, OpenDoor, Slide, Press, Pour and HandOver report their stages from the standard's table, including release and settle stages that were not measured before. - Twist scores the executed end-effector rotation about the knob axis, with the axis probed from the asset; the knob joint, which over-rotates because the gripper wedges against it, is now a diagnostic. - HandOver's delivery budget is the derived 3 x TASK_POSITION_TOLERANCE_M. Tests read BENCHMARK_STANDARD.md and check the stage table, the failure taxonomy and the tolerance constants against it, so the code cannot drift from the document, plus the stage bookkeeping and the two shared scene rules. Co-Authored-By: Claude Opus 5 --- .../atomic_action/axis_align_benchmark.py | 129 +++-- scripts/benchmark/atomic_action/common.py | 535 +++++++++++++----- .../atomic_action/hand_over_benchmark.py | 186 ++++-- .../move_end_effector_benchmark.py | 74 ++- .../move_held_object_benchmark.py | 76 ++- .../atomic_action/move_joints_benchmark.py | 68 ++- .../atomic_action/open_door_benchmark.py | 127 +++-- .../atomic_action/pickup_benchmark.py | 73 ++- .../atomic_action/place_benchmark.py | 97 +++- .../benchmark/atomic_action/pour_benchmark.py | 81 +-- .../atomic_action/press_benchmark.py | 110 ++-- .../atomic_action/slide_benchmark.py | 69 ++- .../atomic_action/twist_benchmark.py | 197 ++++++- tests/benchmark/atomic_action/__init__.py | 21 + .../test_measurement_standard.py | 222 ++++++++ 15 files changed, 1485 insertions(+), 580 deletions(-) create mode 100644 tests/benchmark/atomic_action/__init__.py create mode 100644 tests/benchmark/atomic_action/test_measurement_standard.py diff --git a/scripts/benchmark/atomic_action/axis_align_benchmark.py b/scripts/benchmark/atomic_action/axis_align_benchmark.py index 8e6b0f9a7..6bbc6a0b1 100644 --- a/scripts/benchmark/atomic_action/axis_align_benchmark.py +++ b/scripts/benchmark/atomic_action/axis_align_benchmark.py @@ -16,10 +16,10 @@ """Benchmark AxisAlign atomic action on the tutorial cube. -Reports the four-level success ladder (planning_success, motion_valid, -execution_success, task_success). Task success requires the angle between the -object's internal axis and the commanded target axis to fall within tolerance -at the end of the commanded alignment segment. +Reports the ordered stages defined for AxisAlign in ``BENCHMARK_STANDARD.md``: +grasped, aligned, held. The angle between the object's internal axis and the +commanded target axis is scored at the end of the commanded alignment segment, +and the object must still be held, and not dropped, after the settle. Run: embodichain benchmark atomic-action --action axis_align """ @@ -33,17 +33,21 @@ from scripts.benchmark.atomic_action.common import ( CPU_MEMORY_BACKEND, DEFAULT_REPLAY_STEPS_PER_WAYPOINT, - REPLAY_TRACKING_TOLERANCE_RAD, - SuccessLadder, + ENGAGED_MIN_DISPLACEMENT, + SKILL_STAGES, + StageLadder, + TASK_ROTATION_TOLERANCE_RAD, add_common_benchmark_args, add_grasp_benchmark_args, - build_ladder_leaderboard, + build_stage_leaderboard, build_video_output_path, check_motion_valid, ensure_repo_root, ensure_torch, + dropped_below_support, format_float, - replay_and_track_scalar, + object_position_tuple, + replay_and_track_channels, replay_trajectory_with_recording, reset_rigid_object, reset_robot, @@ -61,10 +65,6 @@ PRE_GRASP_DISTANCE = 0.15 LIFT_HEIGHT = 0.16 REPLAY_HOLD_STEPS = 60 -# Angle between the object's internal axis and the commanded target axis. -# 0.15 rad (8.6 deg) matches the articulated-skill angular tolerance used by -# OpenDoor and Twist; see BENCHMARK_STANDARD.md for the measured basis. -AXIS_ALIGN_TOLERANCE_RAD = 0.15 @dataclass(frozen=True) @@ -179,9 +179,9 @@ def _run_case( args: argparse.Namespace, recorded_count: int, ) -> dict[str, object]: - """Run one AxisAlign case through the full four-level success ladder.""" + """Run one AxisAlign case through its stages.""" torch = ensure_torch() - ladder = SuccessLadder() + ladder = StageLadder(stages=SKILL_STAGES["axis_align"]) reset_robot(robot, initial_qpos) reset_rigid_object(obj, initial_object_pose) sim.update(step=10) @@ -199,21 +199,22 @@ def _run_case( ) except Exception as exc: print(f" planner exception: {type(exc).__name__}: {exc}") - ladder.fail("planning_success", "planner_exception") + ladder.fail("grasped", "planner_exception") return _case_result(case, repeat, ladder, 0.0, None, None, initial_angle, "") - ladder.planning_success = bool(result.plan_success.all().item()) + plan_success = bool(result.plan_success.all().item()) traj = result.trajectory.positions - if not ladder.planning_success: - ladder.fail("planning_success", "planner_reported_failure") + if not plan_success: + ladder.fail("grasped", "planner_reported_failure") return _case_result( case, repeat, ladder, elapsed, mem_delta, None, initial_angle, "", peak_gpu ) - motion_valid, motion_reason = check_motion_valid(traj, robot) + motion_valid, motion_detail = check_motion_valid(traj, robot) ladder.motion_valid = motion_valid - if not motion_valid: - ladder.fail("motion_valid", motion_reason) + ladder.motion_detail = motion_detail + if motion_detail == "non_finite_trajectory": + ladder.fail("grasped", "planner_reported_failure") return _case_result( case, repeat, @@ -233,38 +234,52 @@ def _run_case( measure_waypoint = int(traj.shape[1]) - 1 try: - trace = replay_and_track_scalar( + traces = replay_and_track_channels( sim=sim, robot=robot, traj=traj, - read_value=lambda _index: object_axis_angle_rad( - obj, internal_axis, target_axis - ), + readers={ + "angle": lambda _index: object_axis_angle_rad( + obj, internal_axis, target_axis + ), + "height": lambda _index: object_position_tuple(obj)[2], + }, measure_waypoint=measure_waypoint, steps_per_waypoint=DEFAULT_REPLAY_STEPS_PER_WAYPOINT, hold_steps=REPLAY_HOLD_STEPS, ) - ladder.execution_success = trace is not None except Exception as exc: print(f" replay exception: {type(exc).__name__}: {exc}") - trace = None - ladder.fail("execution_success", "controller_tracking_failure") - - if trace is not None and ( - trace.max_tracking_error_rad > REPLAY_TRACKING_TOLERANCE_RAD - ): - ladder.execution_success = False - ladder.fail("execution_success", "controller_tracking_failure") - elif ladder.execution_success and trace is not None: + traces = None + + trace = None if traces is None else traces["angle"] + if trace is None: + ladder.fail("grasped", "invalid_case") + else: + height = traces["height"] + ladder.max_tracking_error_rad = trace.max_tracking_error_rad + # The object can only be reoriented while the hand holds it, so the + # axis having moved off its initial angle is the evidence of a grasp. + ladder.record( + "grasped", + abs(trace.measured_displacement) > ENGAGED_MIN_DISPLACEMENT, + "object_not_grasped", + ) # The tracked scalar is already the axis angle error to the target. - ladder.task_success = trace.measured_position <= AXIS_ALIGN_TOLERANCE_RAD - if not ladder.task_success: - ladder.fail("task_success", "task_goal_miss") - elif not ladder.failure_stage: - ladder.fail("execution_success", "controller_tracking_failure") + ladder.record( + "aligned", + trace.measured_position <= TASK_ROTATION_TOLERANCE_RAD, + "task_goal_miss", + ) + dropped = dropped_below_support(height.min_position, height.initial_position) + ladder.record( + "held", + (not dropped) and trace.settled_position <= TASK_ROTATION_TOLERANCE_RAD, + "object_dropped" if dropped else "task_goal_miss", + ) video_path = "" - if should_record_case(args, recorded_count, ladder.task_success): + if should_record_case(args, recorded_count, ladder.success): reset_robot(robot, initial_qpos) reset_rigid_object(obj, initial_object_pose) recorded = replay_trajectory_with_recording( @@ -295,7 +310,7 @@ def _run_case( def _case_result( case: AxisAlignCase, repeat: int, - ladder: SuccessLadder, + ladder: StageLadder, elapsed: float, mem_delta: dict[str, float] | None, trace, @@ -323,7 +338,7 @@ def _case_result( "trajectory_waypoints": ( int(traj.shape[1]) if traj is not None and traj.ndim >= 3 else 0 ), - "success": ladder.task_success, + "success": ladder.success, "video_path": video_path, } @@ -347,7 +362,7 @@ def _build_rows(results: list[dict[str, object]]): "trajectory_waypoints": result["trajectory_waypoints"], } ) - ladder: SuccessLadder = result["ladder"] # type: ignore[assignment] + ladder: StageLadder = result["ladder"] # type: ignore[assignment] metric_rows.append( { "sample_size": 1, @@ -355,7 +370,6 @@ def _build_rows(results: list[dict[str, object]]): "case_id": result["case_id"], "align_case": result["align_case"], "target_axis": result["target_axis"], - "success_rate": f"{float(ladder.task_success):.6f}", **ladder.as_row_fields(), "max_tracking_error_rad": format_float( result["max_tracking_error_rad"], 4 @@ -468,19 +482,23 @@ def run_all_benchmarks(args: argparse.Namespace | None = None) -> Path: results.append(result) if result["video_path"]: video_paths.append(str(result["video_path"])) - ladder: SuccessLadder = result["ladder"] # type: ignore[assignment] + ladder: StageLadder = result["ladder"] # type: ignore[assignment] + stages = " ".join( + f"{stage}={'ok' if ladder.passed[stage] else 'FAIL'}" + for stage in ladder.stages + if stage in ladder.passed + ) print( f" {result['case_id']:<22} " f"time={result['cost_time_ms']:>9.2f} ms | " - f"plan={ladder.planning_success} valid={ladder.motion_valid} " - f"exec={ladder.execution_success} task={ladder.task_success} " + f"{stages} " f"angle0={format_float(result['initial_axis_angle_rad'], 4)} " f"angle={format_float(result['final_axis_angle_rad'], 4)} " f"[{ladder.failure_reason or 'ok'}]" ) perf_rows, metric_rows = _build_rows(results) - leaderboard_rows = build_ladder_leaderboard("axis_align", results) + leaderboard_rows = build_stage_leaderboard("axis_align", results) report_path = write_markdown_report( benchmark_name="atomic_action_axis_align", perf_rows=perf_rows, @@ -491,11 +509,14 @@ def run_all_benchmarks(args: argparse.Namespace | None = None) -> Path: f"CPU memory backend: {CPU_MEMORY_BACKEND}", f"Grasp samples: {n_sample}", f"Object internal axis: {INTERNAL_AXIS}.", - "task_success requires the angle between the object's internal axis " - "and the commanded target axis to be at most " - f"{AXIS_ALIGN_TOLERANCE_RAD:.3f} rad " - f"({math.degrees(AXIS_ALIGN_TOLERANCE_RAD):.1f} deg) at the end of " - "the commanded 'manipulate' segment, while the object is still held.", + "Stages: grasped (the object's axis left its initial angle), " + "aligned (the angle between the object's internal axis and the " + f"commanded target axis is at most {TASK_ROTATION_TOLERANCE_RAD:.3f} " + f"rad, {math.degrees(TASK_ROTATION_TOLERANCE_RAD):.1f} deg, at the " + "end of the commanded 'manipulate' segment), held (it is still " + "within that angle after the settle and was never dropped).", + "motion_valid and max_tracking_error_rad are diagnostics; neither " + "fails a stage.", "initial_axis_angle_rad is the angle before the skill runs; a case " "only demonstrates alignment when it starts meaningfully misaligned.", "Planning time excludes a discarded warm-up compile and includes " diff --git a/scripts/benchmark/atomic_action/common.py b/scripts/benchmark/atomic_action/common.py index 64bad2f58..7f53cf706 100644 --- a/scripts/benchmark/atomic_action/common.py +++ b/scripts/benchmark/atomic_action/common.py @@ -26,7 +26,7 @@ import sys import time from collections.abc import Sequence -from dataclasses import dataclass +from dataclasses import dataclass, field from datetime import datetime from pathlib import Path from typing import Callable, Literal @@ -44,9 +44,26 @@ DEFAULT_VIDEO_HEIGHT = 480 DEFAULT_VIDEO_HOLD_STEPS = 120 DEFAULT_VIDEO_CASE_LIMIT = 0 +# Tolerance sets from BENCHMARK_STANDARD.md section 0. The split follows the +# binding contract rather than the skill: MoveEndEffector and MoveJoints bind +# only a motion endpoint and are scored after the terminal settle with the +# drive converged, while the skills that also bind a grasp endpoint are scored +# at the end of the actuating segment with the arm contact-loaded. Every value +# is 1.8x the worst error measured for its set, absolute, and never scaled by +# the commanded magnitude. +PRIMITIVE_POSITION_TOLERANCE_M = 0.003 +PRIMITIVE_ROTATION_TOLERANCE_RAD = 0.00524 +TASK_POSITION_TOLERANCE_M = 0.01 +TASK_ROTATION_TOLERANCE_RAD = 0.08727 +# Thresholds that are not an error against a commanded value. PHYSICAL_PICK_MIN_LIFT_M = 0.04 -PHYSICAL_PLACE_XY_TOLERANCE_M = 0.10 -PHYSICAL_MOVE_HELD_OBJECT_XYZ_TOLERANCE_M = 0.12 +PHYSICAL_PRESS_MIN_STROKE_RATIO = 0.80 +PHYSICAL_DROP_MARGIN_M = 0.15 +# Floor separating a target the skill actually engaged from physics noise. A +# joint the hand never touched holds its position to far less than this in the +# shipped assets, and it is two orders below every tolerance above. Radians for +# a revolute target, metres for a prismatic one. +ENGAGED_MIN_DISPLACEMENT = 1.0e-3 PHYSICAL_VALIDATION_HOLD_STEPS = 80 SIDE_GRASP_MAX_OPEN_AXIS_ABS_Z = 0.35 SIDE_GRASP_OPEN_AXIS_Z_COST_WEIGHT = 0.5 @@ -834,13 +851,18 @@ def replay_trajectory_for_physical_validation( on_step: Callable[[int], None] | None = None, hold_steps: int = PHYSICAL_VALIDATION_HOLD_STEPS, ) -> tuple[float, float, float] | None: - """Replay a trajectory in physics and return the final object position.""" + """Replay a trajectory in physics and return the final object position. + + The replay runs at :data:`DEFAULT_REPLAY_STEPS_PER_WAYPOINT`, the rate the + standard calibrates every scene measurement at, so an object read here is + comparable with one read through :func:`replay_and_track_channels`. + """ if traj is None or getattr(traj, "ndim", 0) < 3 or traj.shape[1] == 0: return None for waypoint_index in range(traj.shape[1]): robot.set_qpos(traj[:, waypoint_index, :]) - sim.update(step=4) + sim.update(step=DEFAULT_REPLAY_STEPS_PER_WAYPOINT) if on_step is not None: on_step(waypoint_index) @@ -1098,31 +1120,43 @@ def record_static_scene_video( return None +# Task-level subset of the taxonomy in +# scripts/benchmark/motion_generation/BENCHMARK_DESIGN.md section 7. The +# trajectory-level reasons stay with that suite: these benchmarks measure +# skills, so neither a plan's validity nor the drive's tracking error fails one +# here. Both are reported as diagnostics instead. FAILURE_TAXONOMY: tuple[str, ...] = ( "invalid_case", "unsupported_capability", - "checkpoint_load_failure", "planner_exception", "planner_reported_failure", "timeout", - "non_finite_trajectory", - "waypoint_miss", - "joint_limit_violation", - "dynamic_limit_violation", - "self_collision", - "environment_collision", - "controller_tracking_failure", "object_not_grasped", "object_dropped", "release_failure", "task_goal_miss", ) -LADDER_STAGES: tuple[str, ...] = ( - "planning_success", - "motion_valid", - "execution_success", - "task_success", -) +# Ordered stages per skill, from BENCHMARK_STANDARD.md section 1. Each stage is +# a scene measurement taken at the end of the segment where the skill stops +# acting on what the stage is about, and a stage is reached only when every +# earlier stage passed. +SKILL_STAGES: dict[str, tuple[str, ...]] = { + "move_end_effector": ("reached",), + "move_joints": ("reached",), + "pick_up": ("grasped", "lifted", "held"), + "axis_align": ("grasped", "aligned", "held"), + "move_held_object": ("transported",), + "place": ("released", "placed", "stable"), + "pour": ("poured",), + "push_object": ("contacted", "pushed", "on_support_plane"), + "press": ("contacted", "pressed"), + "slide": ("grasped", "slid", "released"), + "open_door": ("grasped", "opened", "released"), + "twist": ("grasped", "twisted", "released"), + "coordinated_pickment": ("dual_grasped", "lifted", "moved", "held"), + "coordinated_placement": ("aligned", "released", "stable"), + "hand_over": ("grasped", "transferred", "handed_over", "placed"), +} JOINT_LIMIT_TOLERANCE_RAD = 1e-3 # Physics steps per replayed waypoint. The planners emit one waypoint per # physics step, but the simulated position drive cannot converge that fast: a @@ -1132,71 +1166,164 @@ def record_static_scene_video( # target-joint measurement has converged to within 0.006 rad of the 32-step # value, so the replay measures the skill rather than the drive. DEFAULT_REPLAY_STEPS_PER_WAYPOINT = 16 -# Maximum arm tracking error tolerated before a case is reported as a -# controller failure instead of crediting or blaming the skill. -REPLAY_TRACKING_TOLERANCE_RAD = 0.10 @dataclass -class SuccessLadder: - """Layered atomic-action outcome shared by every skill benchmark. - - The stages follow the suite protocol in - ``scripts/benchmark/motion_generation/BENCHMARK_DESIGN.md`` section 7: - ``planning_success -> motion_valid -> execution_success -> task_success``. - Every stage is recorded, not only the final verdict. +class StageLadder: + """Ordered per-skill stages for one benchmark case. + + The stages come from :data:`SKILL_STAGES`, which mirrors + ``BENCHMARK_STANDARD.md`` section 1. A stage is reached only when every + earlier stage passed, so a report localizes the failure instead of + collapsing it into one boolean, and a closing stage counts: a skill that + lifts an object and then drops it did not pick it up. + + Trajectory validity and replay tracking ride along as diagnostics. + Neither fails a stage; they belong to + ``scripts/benchmark/motion_generation/``. """ - planning_success: bool = False - motion_valid: bool = False - execution_success: bool = False - task_success: bool = False + stages: tuple[str, ...] + passed: dict[str, bool] = field(default_factory=dict) failure_stage: str = "" failure_reason: str = "" + motion_valid: bool | None = None + motion_detail: str = "" + max_tracking_error_rad: float | None = None + + def __post_init__(self) -> None: + """Validate that the case declares at least one stage.""" + if not self.stages: + raise ValueError("A skill must declare at least one stage.") - def fail(self, stage: str, reason: str) -> "SuccessLadder": - """Record the first failing stage and its taxonomy reason. + @property + def success(self) -> bool: + """Whether every declared stage passed.""" + return all(self.passed.get(stage, False) for stage in self.stages) + + def reached(self, stage: str) -> bool: + """Whether ``stage`` was reached, i.e. every earlier stage passed.""" + index = self.stages.index(stage) + return all(self.passed.get(name, False) for name in self.stages[:index]) + + def record(self, stage: str, passed: bool, reason: str = "") -> "StageLadder": + """Record the next stage's outcome in declaration order. + + Calls after the first failure are ignored: those stages were never + reached, and recording them would credit or blame a measurement the + skill never got to make. Args: - stage: Ladder stage name from :data:`LADDER_STAGES`. - reason: Failure reason from :data:`FAILURE_TAXONOMY`. + stage: Stage name, which must be the next unrecorded stage. + passed: Whether the stage's scene measurement passed. + reason: Failure reason from :data:`FAILURE_TAXONOMY`, required + when ``passed`` is False. Returns: - This ladder, to allow ``return ladder.fail(...)``. + This ladder, to allow chained calls. """ - if stage not in LADDER_STAGES: - raise ValueError(f"Unknown ladder stage {stage!r}.") - if reason not in FAILURE_TAXONOMY: - raise ValueError(f"Unknown failure reason {reason!r}.") - if not self.failure_stage: + if stage not in self.stages: + raise ValueError(f"Unknown stage {stage!r} for {self.stages}.") + if self.failure_stage: + return self + expected = self.stages[len(self.passed)] + if stage != expected: + raise ValueError( + f"Stage {stage!r} recorded out of order; expected {expected!r}." + ) + self.passed[stage] = bool(passed) + if not passed: + if reason not in FAILURE_TAXONOMY: + raise ValueError(f"Unknown failure reason {reason!r}.") self.failure_stage = stage self.failure_reason = reason return self + def fail(self, stage: str, reason: str) -> "StageLadder": + """Record ``stage`` as failed with a taxonomy reason.""" + return self.record(stage, False, reason) + def as_row_fields(self) -> dict[str, object]: - """Return the ladder columns shared by every benchmark metric table.""" - return { - "planning_success": f"{float(self.planning_success):.6f}", - "motion_valid": f"{float(self.motion_valid):.6f}", - "execution_success": f"{float(self.execution_success):.6f}", - "task_success": f"{float(self.task_success):.6f}", - "failure_stage": self.failure_stage or "N/A", - "failure_reason": self.failure_reason or "N/A", - } + """Return this case's stage columns plus the shared diagnostics. + + An unreached stage is ``N/A`` rather than zero, so a stage rate is + never diluted by cases that never got there. + """ + fields: dict[str, object] = {} + for stage in self.stages: + if stage in self.passed: + fields[stage] = f"{float(self.passed[stage]):.6f}" + else: + fields[stage] = "N/A" + fields["success"] = f"{float(self.success):.6f}" + fields["failure_stage"] = self.failure_stage or "N/A" + fields["failure_reason"] = self.failure_reason or "N/A" + fields["motion_valid"] = ( + "N/A" if self.motion_valid is None else f"{float(self.motion_valid):.6f}" + ) + fields["max_tracking_error_rad"] = format_float(self.max_tracking_error_rad, 4) + return fields + + +def dropped_below_support(min_height_m: float, support_height_m: float) -> bool: + """Whether a held object fell out of the skill's control during a replay. + + Cross-cutting rule from ``BENCHMARK_STANDARD.md`` section 1: the minimum + height over the whole replay is used, not the final one, because an object + dropped mid-motion can still roll to rest near the target. + + Args: + min_height_m: Lowest world z the object reached during the replay. + support_height_m: Height of the support plane it was manipulated over. + + Returns: + True when the object fell more than :data:`PHYSICAL_DROP_MARGIN_M` + below the support plane at any point. + """ + if not math.isfinite(min_height_m) or not math.isfinite(support_height_m): + return False + return (support_height_m - min_height_m) > PHYSICAL_DROP_MARGIN_M + + +def hand_is_released(robot, hand_control_part: str, open_qpos, close_qpos) -> bool: + """Whether a gripper ended a replay nearer its open command than its closed one. + + Release is a scene measurement like any other stage: the standard asks + whether the hand let go, not whether a release segment was planned. The + comparison is against the skill's own two commands, so it holds for any + gripper without a per-asset threshold. + + Args: + robot: Robot holding the gripper. + hand_control_part: Name of the gripper control part. + open_qpos: Open command used by the skill for that part. + close_qpos: Closed command used by the skill for that part. + + Returns: + True when the achieved hand position is closer to the open command. + """ + achieved = robot.get_qpos(name=hand_control_part, target=False)[0] + to_open = float((achieved - open_qpos.to(achieved.device)).abs().max()) + to_close = float((achieved - close_qpos.to(achieved.device)).abs().max()) + return to_open < to_close def check_motion_valid(traj, robot) -> tuple[bool, str]: - """Validate a planned trajectory independently of any planner's own epsilon. + """Check a planned trajectory as a diagnostic, not as a success gate. A trajectory is valid when it is non-empty, entirely finite, and stays - inside the robot's joint position limits. + inside the robot's joint position limits. Whether a plan is valid belongs + to ``scripts/benchmark/motion_generation/``; here the answer is reported + alongside the stages so a reader can judge a scene measurement, and only an + unreplayable trajectory stops a case. Args: traj: Planned positions with shape ``(num_envs, num_waypoints, dof)``. robot: Robot providing ``get_qpos_limits``. Returns: - ``(motion_valid, failure_reason)``; the reason is empty when valid. + ``(motion_valid, detail)``; the detail names the diagnostic that fired + and is empty when the trajectory is valid. """ torch = ensure_torch() if traj is None or getattr(traj, "ndim", 0) < 3 or traj.shape[1] == 0: @@ -1239,7 +1366,10 @@ class JointDisplacementTrace: target joint: once the hand releases and the arm retracts, an undriven hinge, drawer, or knob is free to rebound, and the post-replay resting value describes the asset's dynamics rather than the skill's achievement. - ``settled_position`` retains that resting value for diagnostics. + ``settled_position`` retains that resting value for diagnostics, and + ``min_position`` and ``max_position`` bound the whole replay, which is what + the cross-cutting drop rule needs: an object dropped mid-motion can still + roll to rest near the target. """ initial_position: float @@ -1248,6 +1378,8 @@ class JointDisplacementTrace: measured_displacement: float settled_displacement: float peak_signed_displacement: float + min_position: float = 0.0 + max_position: float = 0.0 max_tracking_error_rad: float = 0.0 @@ -1295,32 +1427,35 @@ def waypoint_step_counts( return counts -def replay_and_track_scalar( +def replay_and_track_channels( sim, robot, traj, - read_value: Callable[[int], float], + readers: dict[str, Callable[[int], float]], measure_waypoint: int | None = None, waypoint_dt=None, physics_dt: float = 0.0, steps_per_waypoint: int = DEFAULT_REPLAY_STEPS_PER_WAYPOINT, hold_steps: int = 60, hold_substeps: int = 2, -) -> JointDisplacementTrace | None: - """Replay a trajectory while sampling one scalar scene measurement. +) -> dict[str, JointDisplacementTrace] | None: + """Replay a trajectory once while sampling several scalar measurements. - This is the shared physical-replay primitive for every skill benchmark. - ``read_value`` may read an articulation joint, an object tilt angle, or any - other scalar the skill's task-success rule needs. + This is the shared physical-replay primitive for every skill benchmark. A + skill that is scored on one signal and reports another as a diagnostic -- + Twist, scored on the executed end-effector rotation while the knob joint is + only reported -- reads both from a single replay, so the two describe the + same execution. Args: sim: Simulation manager to step. robot: Robot receiving the replayed joint positions. traj: Planned positions with shape ``(num_envs, num_waypoints, dof)``. - read_value: Returns the tracked scalar at the current simulation - state, given the waypoint index just executed. The index is ``-1`` - before the first waypoint and ``waypoint_count`` during the hold, - which lets a skill capture a reference at a segment boundary. + readers: Named callables returning one scalar each at the current + simulation state, given the waypoint index just executed. The index + is ``-1`` before the first waypoint and ``waypoint_count`` during + the hold, which lets a skill capture a reference at a segment + boundary. measure_waypoint: Waypoint index scored as the skill's achievement. Defaults to the final waypoint. waypoint_dt: Planned per-waypoint arrival intervals for one @@ -1332,10 +1467,12 @@ def replay_and_track_scalar( hold_substeps: Physics steps per terminal hold iteration. Returns: - The measurement trace, or None when the trajectory is unusable. + One trace per reader, or None when the trajectory is unusable. """ if traj is None or getattr(traj, "ndim", 0) < 3 or traj.shape[1] == 0: return None + if not readers: + raise ValueError("At least one reader is required.") waypoint_count = int(traj.shape[1]) if measure_waypoint is None: @@ -1346,9 +1483,9 @@ def replay_and_track_scalar( waypoint_dt, physics_dt, waypoint_count, steps_per_waypoint ) - initial_position = read_value(-1) - samples = [initial_position] - measured_position = initial_position + initial = {name: read(-1) for name, read in readers.items()} + samples = {name: [value] for name, value in initial.items()} + measured = dict(initial) max_tracking_error = 0.0 for waypoint_index in range(waypoint_count): commanded = traj[:, waypoint_index, :] @@ -1360,29 +1497,82 @@ def replay_and_track_scalar( achieved = robot.get_qpos(target=False) error = float((achieved - commanded).abs().max()) max_tracking_error = max(max_tracking_error, error) - value = read_value(waypoint_index) - samples.append(value) - if waypoint_index == measure_waypoint: - measured_position = value + for name, read in readers.items(): + value = read(waypoint_index) + samples[name].append(value) + if waypoint_index == measure_waypoint: + measured[name] = value final_qpos = traj[:, -1, :] for _ in range(hold_steps): robot.set_qpos(final_qpos) sim.update(step=hold_substeps) - samples.append(read_value(waypoint_count)) - - settled_position = samples[-1] - displacements = [value - initial_position for value in samples] - peak = max(displacements, key=abs) - return JointDisplacementTrace( - initial_position=initial_position, - measured_position=measured_position, - settled_position=settled_position, - measured_displacement=measured_position - initial_position, - settled_displacement=settled_position - initial_position, - peak_signed_displacement=peak, - max_tracking_error_rad=max_tracking_error, + for name, read in readers.items(): + samples[name].append(read(waypoint_count)) + + traces: dict[str, JointDisplacementTrace] = {} + for name, values in samples.items(): + displacements = [value - initial[name] for value in values] + traces[name] = JointDisplacementTrace( + initial_position=initial[name], + measured_position=measured[name], + settled_position=values[-1], + measured_displacement=measured[name] - initial[name], + settled_displacement=values[-1] - initial[name], + peak_signed_displacement=max(displacements, key=abs), + min_position=min(values), + max_position=max(values), + max_tracking_error_rad=max_tracking_error, + ) + return traces + + +def replay_and_track_scalar( + sim, + robot, + traj, + read_value: Callable[[int], float], + measure_waypoint: int | None = None, + waypoint_dt=None, + physics_dt: float = 0.0, + steps_per_waypoint: int = DEFAULT_REPLAY_STEPS_PER_WAYPOINT, + hold_steps: int = 60, + hold_substeps: int = 2, +) -> JointDisplacementTrace | None: + """Replay a trajectory while sampling one scalar scene measurement. + + Args: + sim: Simulation manager to step. + robot: Robot receiving the replayed joint positions. + traj: Planned positions with shape ``(num_envs, num_waypoints, dof)``. + read_value: Returns the tracked scalar at the current simulation state, + given the waypoint index just executed. + measure_waypoint: Waypoint index scored as the skill's achievement. + Defaults to the final waypoint. + waypoint_dt: Planned per-waypoint arrival intervals for one + environment. When given, the replay runs at the planned rate. + physics_dt: Simulation physics timestep, required with ``waypoint_dt``. + steps_per_waypoint: Fallback physics steps per waypoint when no + planned timing is available. + hold_steps: Terminal hold iterations after the last waypoint. + hold_substeps: Physics steps per terminal hold iteration. + + Returns: + The measurement trace, or None when the trajectory is unusable. + """ + traces = replay_and_track_channels( + sim=sim, + robot=robot, + traj=traj, + readers={"value": read_value}, + measure_waypoint=measure_waypoint, + waypoint_dt=waypoint_dt, + physics_dt=physics_dt, + steps_per_waypoint=steps_per_waypoint, + hold_steps=hold_steps, + hold_substeps=hold_substeps, ) + return None if traces is None else traces["value"] def replay_and_track_joint( @@ -1444,10 +1634,11 @@ def read(waypoint_index: int) -> float: @dataclass class ContactCaseOutcome: - """Result of running one articulated-contact case through the ladder.""" + """Result of running one articulated-contact case through its stages.""" - ladder: SuccessLadder + ladder: StageLadder trace: JointDisplacementTrace | None = None + channels: dict[str, JointDisplacementTrace] = field(default_factory=dict) elapsed_s: float = 0.0 cpu_delta_mb: float = 0.0 gpu_delta_mb: float = 0.0 @@ -1471,16 +1662,25 @@ def run_articulated_contact_case( build_invocation: Callable[[], tuple[object, object]], joint_index: int, actuation_segment: str, - evaluate: Callable[[JointDisplacementTrace], tuple[bool, str]], + stages: tuple[str, ...], + evaluate: Callable[[StageLadder, dict[str, JointDisplacementTrace]], None], + build_extra_readers: ( + Callable[[object], dict[str, Callable[[int], float]]] | None + ) = None, + steps_per_waypoint: int = DEFAULT_REPLAY_STEPS_PER_WAYPOINT, hold_steps: int = 60, ) -> ContactCaseOutcome: - """Run one contact skill through planning, validation, and physical replay. + """Run one contact skill through planning, physical replay, and its stages. The target joint is scored at the last waypoint of ``actuation_segment``, the point where the skill stops commanding the joint. Later segments open the hand and retract the arm, after which an undriven hinge, drawer, or knob moves under its own dynamics rather than the skill's control. + Trajectory validity and the replay's peak tracking error are recorded on + the ladder as diagnostics. Neither fails the skill: a skill that opened the + door is not failed because the drive lagged the plan. + Args: sim: Simulation manager to step. robot: Robot executing the trajectory. @@ -1489,14 +1689,23 @@ def run_articulated_contact_case( build_invocation: Returns the ``(invocation, context)`` pair to compile. joint_index: Index of the manipulated joint in ``articulation``. actuation_segment: Segment name whose end is scored. - evaluate: Maps the displacement trace to ``(task_success, reason)``. + stages: Ordered stage names for this skill, from :data:`SKILL_STAGES`. + evaluate: Records each stage on the ladder from the replay traces, + which arrive as a name-to-trace mapping including ``"joint"``. + build_extra_readers: Given the compiled result, returns additional + named scalars to sample during the same replay. A skill scored on + one signal that reports another -- or one whose reference is a + segment boundary it can only know after compiling -- builds its + readers here. + steps_per_waypoint: Physics steps per replayed waypoint. hold_steps: Terminal hold iterations after the last waypoint. Returns: - The ladder result, displacement trace, and cost metrics for the case. + The stage result, displacement trace, and cost metrics for the case. """ - ladder = SuccessLadder() + ladder = StageLadder(stages=stages) outcome = ContactCaseOutcome(ladder=ladder) + first_stage = stages[0] invocation, context = build_invocation() try: @@ -1505,7 +1714,7 @@ def run_articulated_contact_case( ) except Exception as exc: print(f" planner exception: {type(exc).__name__}: {exc}") - ladder.fail("planning_success", "planner_exception") + ladder.fail(first_stage, "planner_exception") return outcome outcome.elapsed_s = elapsed @@ -1513,58 +1722,63 @@ def run_articulated_contact_case( outcome.gpu_delta_mb = mem_delta["gpu_mb"] outcome.peak_gpu_mb = peak_gpu - ladder.planning_success = bool(result.plan_success.all().item()) - traj = result.trajectory.positions - outcome.trajectory = traj - if not ladder.planning_success: - ladder.fail("planning_success", "planner_reported_failure") + if not bool(result.plan_success.all().item()): + ladder.fail(first_stage, "planner_reported_failure") return outcome - motion_valid, motion_reason = check_motion_valid(traj, robot) + traj = result.trajectory.positions + outcome.trajectory = traj + motion_valid, motion_detail = check_motion_valid(traj, robot) ladder.motion_valid = motion_valid - if not motion_valid: - ladder.fail("motion_valid", motion_reason) + ladder.motion_detail = motion_detail + if motion_detail == "non_finite_trajectory": + # There is nothing to replay, so no stage can be measured. The planner + # reported success and returned an unusable trajectory, which is the + # closest task-level reason the taxonomy carries. + ladder.fail(first_stage, "planner_reported_failure") return outcome try: measure_waypoint = result.segment(0, actuation_segment).stop - 1 - except (KeyError, AttributeError, IndexError): - measure_waypoint = int(traj.shape[1]) - 1 + except (KeyError, AttributeError, IndexError) as exc: + # Falling back to the final waypoint would score the target after the + # hand released and the arm retracted, which is the rebound this + # function exists to avoid. A missing segment is a case definition + # error, not a skill failure. + print(f" missing segment {actuation_segment!r}: {type(exc).__name__}: {exc}") + ladder.fail(first_stage, "invalid_case") + return outcome + + def read_joint(waypoint_index: int) -> float: + del waypoint_index + return float(articulation.get_qpos(target=False)[0, joint_index]) + readers: dict[str, Callable[[int], float]] = {"joint": read_joint} + if build_extra_readers is not None: + readers.update(build_extra_readers(result)) try: - trace = replay_and_track_joint( + traces = replay_and_track_channels( sim=sim, robot=robot, traj=traj, - articulation=articulation, - joint_index=joint_index, + readers=readers, measure_waypoint=measure_waypoint, - steps_per_waypoint=DEFAULT_REPLAY_STEPS_PER_WAYPOINT, + steps_per_waypoint=steps_per_waypoint, hold_steps=hold_steps, ) - ladder.execution_success = trace is not None except Exception as exc: print(f" replay exception: {type(exc).__name__}: {exc}") - trace = None - ladder.fail("execution_success", "controller_tracking_failure") + traces = None + trace = None if traces is None else traces["joint"] outcome.trace = trace - if trace is not None and ( - trace.max_tracking_error_rad > REPLAY_TRACKING_TOLERANCE_RAD - ): - # The executed motion did not match the plan, so the manipulated joint - # reading describes the drive, not the skill. Do not score task success. - ladder.execution_success = False - ladder.fail("execution_success", "controller_tracking_failure") + outcome.channels = dict(traces or {}) + if trace is None: + ladder.fail(first_stage, "invalid_case") return outcome - if ladder.execution_success and trace is not None: - task_success, reason = evaluate(trace) - ladder.task_success = task_success - if not task_success: - ladder.fail("task_success", reason) - elif not ladder.failure_stage: - ladder.fail("execution_success", "controller_tracking_failure") + ladder.max_tracking_error_rad = trace.max_tracking_error_rad + evaluate(ladder, outcome.channels) return outcome @@ -1590,43 +1804,46 @@ def warmup_planning(callable_fn: Callable[[], object]) -> bool: return False -def build_ladder_leaderboard( +def build_stage_leaderboard( action_name: str, results: Sequence[dict[str, object]], ) -> list[dict[str, object]]: - """Aggregate ladder stage rates for one action into a leaderboard row. + """Aggregate per-stage rates for one action into a leaderboard row. + + Follows ``BENCHMARK_STANDARD.md`` section 1:: + + stage_success_rate[i] = passed(i) / reached(i) reached(i) = passed(i-1) + success_rate = passed(last) / total + + A stage nobody reached reports ``N/A`` rather than zero, so a rate always + describes the cases that actually got there. Args: action_name: Atomic action identifier. results: Case results each carrying a ``ladder`` entry. Returns: - A single-row leaderboard reporting every ladder stage rate. + A single-row leaderboard reporting every stage rate and the overall + success rate. """ if not results: return [] - count = len(results) - - def rate(stage: str) -> str: - total = sum( - 1.0 - for result in results - if bool(getattr(result["ladder"], stage)) # type: ignore[index] + ladders = [result["ladder"] for result in results] + stages = ladders[0].stages + row: dict[str, object] = {"rank": 1, "algorithm": action_name} + for index, stage in enumerate(stages): + earlier = stages[:index] + reached = sum( + 1 + for ladder in ladders + if all(ladder.passed.get(name, False) for name in earlier) ) - return f"{total / count:.2%}" - - return [ - { - "rank": 1, - "algorithm": action_name, - "planning_success_rate": rate("planning_success"), - "motion_valid_rate": rate("motion_valid"), - "execution_success_rate": rate("execution_success"), - "task_success_rate": rate("task_success"), - "evaluated_cases": count, - } - ] + passed = sum(1 for ladder in ladders if ladder.passed.get(stage, False)) + row[f"{stage}_rate"] = f"{passed / reached:.2%}" if reached else "N/A" + row["success_rate"] = f"{sum(1 for l in ladders if l.success) / len(ladders):.2%}" + row["evaluated_cases"] = len(ladders) + return [row] def format_float(value: float | None, precision: int = 6) -> str: @@ -1719,9 +1936,11 @@ def build_single_action_leaderboard( "add_profile_benchmark_args", "add_video_benchmark_args", "build_video_output_path", - "build_ladder_leaderboard", + "build_stage_leaderboard", "build_single_action_leaderboard", "check_motion_valid", + "dropped_below_support", + "hand_is_released", "ContactCaseOutcome", "create_antipodal_object_semantics", "create_benchmark_object", @@ -1732,6 +1951,7 @@ def build_single_action_leaderboard( "default_position_case_names_for_profile", "describe_object_preset", "ensure_repo_root", + "ENGAGED_MIN_DISPLACEMENT", "ensure_torch", "FAILURE_TAXONOMY", "format_float", @@ -1740,13 +1960,14 @@ def build_single_action_leaderboard( "FULL_POSITION_CASE_NAMES", "JointDisplacementTrace", "JOINT_LIMIT_TOLERANCE_RAD", - "LADDER_STAGES", + "PRIMITIVE_POSITION_TOLERANCE_M", + "PRIMITIVE_ROTATION_TOLERANCE_RAD", "MESH_OBJECT_PRESETS", "MeshObjectPreset", "PICKUP_APPROACH_CASES", - "PHYSICAL_MOVE_HELD_OBJECT_XYZ_TOLERANCE_M", + "PHYSICAL_DROP_MARGIN_M", "PHYSICAL_PICK_MIN_LIFT_M", - "PHYSICAL_PLACE_XY_TOLERANCE_M", + "PHYSICAL_PRESS_MIN_STROKE_RATIO", "PHYSICAL_VALIDATION_HOLD_STEPS", "POSITION_CASES", "PositionCase", @@ -1754,6 +1975,7 @@ def build_single_action_leaderboard( "park_rigid_object", "pickup_approach_direction_tuple", "record_static_scene_video", + "replay_and_track_channels", "replay_and_track_joint", "replay_and_track_scalar", "replay_trajectory_for_physical_validation", @@ -1769,11 +1991,14 @@ def build_single_action_leaderboard( "select_position_cases", "should_record_case", "SIDE_GRASP_MAX_OPEN_AXIS_ABS_Z", + "SKILL_STAGES", "SIDE_GRASP_OPEN_AXIS_Z_COST_WEIGHT", "SMOKE_PICKUP_APPROACH_CASES", "SMOKE_MESH_OBJECT_TYPES", "SMOKE_POSITION_CASE_NAMES", - "SuccessLadder", + "StageLadder", + "TASK_POSITION_TOLERANCE_M", + "TASK_ROTATION_TOLERANCE_RAD", "timed_call", "warmup_planning", "waypoint_step_counts", diff --git a/scripts/benchmark/atomic_action/hand_over_benchmark.py b/scripts/benchmark/atomic_action/hand_over_benchmark.py index bcc728158..9f225b1d8 100644 --- a/scripts/benchmark/atomic_action/hand_over_benchmark.py +++ b/scripts/benchmark/atomic_action/hand_over_benchmark.py @@ -16,9 +16,10 @@ """Benchmark HandOver atomic action on the dual-arm tutorial scene. -Reports the four-level success ladder (planning_success, motion_valid, -execution_success, task_success). Task success requires the object to reach the -commanded delivery pose without ever being dropped during the transfer. +Reports the ordered stages defined for HandOver in ``BENCHMARK_STANDARD.md``: +grasped, transferred, handed_over, placed. The delivery budget is the derived +one the standard gives a skill that breaks and re-establishes contact three +times, and the object must never be dropped during the transfer. Run: embodichain benchmark atomic-action --action hand_over """ @@ -31,18 +32,22 @@ from scripts.benchmark.atomic_action.common import ( CPU_MEMORY_BACKEND, DEFAULT_REPLAY_STEPS_PER_WAYPOINT, - REPLAY_TRACKING_TOLERANCE_RAD, - SuccessLadder, + ENGAGED_MIN_DISPLACEMENT, + SKILL_STAGES, + StageLadder, + TASK_POSITION_TOLERANCE_M, add_common_benchmark_args, add_grasp_benchmark_args, - build_ladder_leaderboard, + build_stage_leaderboard, build_video_output_path, check_motion_valid, ensure_repo_root, ensure_torch, + dropped_below_support, format_float, + hand_is_released, object_position_tuple, - replay_and_track_scalar, + replay_and_track_channels, replay_trajectory_with_recording, reset_rigid_object, reset_robot, @@ -66,12 +71,11 @@ FINAL_OBJECT_XYZ = (0.0, -0.2, 0.6) SETTLE_ITERATIONS = 50 REPLAY_HOLD_STEPS = 60 -# Delivery tolerance on the object's final position. Matches the magnitude of -# the existing MoveHeldObject placement tolerance (0.12 m) used by this suite. -HANDOVER_DELIVERY_TOLERANCE_M = 0.12 -# The object is dropped if it ever falls well below the support surface it was -# lifted from; 0.15 m under the surface clears normal transfer dips. -HANDOVER_DROP_Z_M = SUPPORT_SURFACE_Z - 0.15 +# HandOver breaks and re-establishes contact three times -- source grasp, +# transfer, delivery -- so its delivery budget is the derived one from +# BENCHMARK_STANDARD.md section 0 rather than a constant of its own. +HANDOVER_CONTACT_PHASES = 3 +HANDOVER_DELIVERY_TOLERANCE_M = HANDOVER_CONTACT_PHASES * TASK_POSITION_TOLERANCE_M @dataclass(frozen=True) @@ -164,8 +168,8 @@ def _run_case( args: argparse.Namespace, recorded_count: int, ) -> dict[str, object]: - """Run one HandOver case through the full four-level success ladder.""" - ladder = SuccessLadder() + """Run one HandOver case through its stages.""" + ladder = StageLadder(stages=SKILL_STAGES["hand_over"]) reset_robot(robot, initial_qpos) reset_rigid_object(obj, initial_object_pose) sim.update(step=10) @@ -179,25 +183,26 @@ def _run_case( ) except Exception as exc: print(f" planner exception: {type(exc).__name__}: {exc}") - ladder.fail("planning_success", "planner_exception") + ladder.fail("grasped", "planner_exception") return _case_result(case, repeat, ladder, 0.0, None, None, None, None, "") - ladder.planning_success = bool(result.plan_success.all().item()) + plan_success = bool(result.plan_success.all().item()) traj = result.trajectory.positions - if not ladder.planning_success: + if not plan_success: for plan in result.action_plans: if not plan.plan_success.all(): messages = plan.diagnostics.messages or ("planning failed",) print(f" plan failure [{plan.skill_id}]: {'; '.join(messages)}") - ladder.fail("planning_success", "planner_reported_failure") + ladder.fail("grasped", "planner_reported_failure") return _case_result( case, repeat, ladder, elapsed, mem_delta, None, None, None, "", peak_gpu ) - motion_valid, motion_reason = check_motion_valid(traj, robot) + motion_valid, motion_detail = check_motion_valid(traj, robot) ladder.motion_valid = motion_valid - if not motion_valid: - ladder.fail("motion_valid", motion_reason) + ladder.motion_detail = motion_detail + if motion_detail == "non_finite_trajectory": + ladder.fail("grasped", "planner_reported_failure") return _case_result( case, repeat, @@ -212,48 +217,100 @@ def _run_case( traj, ) - state: dict[str, float] = {"min_z": float("inf")} + from scripts.tutorials.atomic_action.tutorial_utils import ( + get_hand_open_close_qpos, + ) + + initial_position = object_position_tuple(obj) + + def tool_center_point(control_part: str): + pose = robot.compute_fk( + robot.get_qpos(name=control_part), name=control_part, to_matrix=True + ) + return ( + float(pose[0, 0, 3]), + float(pose[0, 1, 3]), + float(pose[0, 2, 3]), + ) def read_delivery_distance(waypoint_index: int) -> float: + del waypoint_index + return xyz_distance_m(object_position_tuple(obj), FINAL_OBJECT_XYZ) + + def read_travel(waypoint_index: int) -> float: + del waypoint_index + return xyz_distance_m(object_position_tuple(obj), initial_position) + + def read_height(waypoint_index: int) -> float: + del waypoint_index + return object_position_tuple(obj)[2] + + def read_hand_balance(waypoint_index: int) -> float: + # Positive while the object is nearer the handing arm, negative once + # the receiving arm is the closer of the two. Comparing the two hands + # against each other needs no grasp radius. del waypoint_index position = object_position_tuple(obj) - state["min_z"] = min(state["min_z"], position[2]) - return xyz_distance_m(position, FINAL_OBJECT_XYZ) + return xyz_distance_m(position, tool_center_point("left_arm")) - xyz_distance_m( + position, tool_center_point("right_arm") + ) try: - trace = replay_and_track_scalar( + traces = replay_and_track_channels( sim=sim, robot=robot, traj=traj, - read_value=read_delivery_distance, + readers={ + "delivery": read_delivery_distance, + "travel": read_travel, + "height": read_height, + "hand_balance": read_hand_balance, + }, steps_per_waypoint=DEFAULT_REPLAY_STEPS_PER_WAYPOINT, hold_steps=REPLAY_HOLD_STEPS, ) - ladder.execution_success = trace is not None except Exception as exc: print(f" replay exception: {type(exc).__name__}: {exc}") - trace = None - ladder.fail("execution_success", "controller_tracking_failure") - - min_z = state["min_z"] if state["min_z"] != float("inf") else None - dropped = min_z is not None and min_z < HANDOVER_DROP_Z_M - if trace is not None and ( - trace.max_tracking_error_rad > REPLAY_TRACKING_TOLERANCE_RAD - ): - ladder.execution_success = False - ladder.fail("execution_success", "controller_tracking_failure") - elif ladder.execution_success and trace is not None: - delivered = trace.settled_position <= HANDOVER_DELIVERY_TOLERANCE_M - ladder.task_success = delivered and not dropped - if not ladder.task_success: - ladder.fail( - "task_success", "object_dropped" if dropped else "task_goal_miss" - ) - elif not ladder.failure_stage: - ladder.fail("execution_success", "controller_tracking_failure") + traces = None + + trace = None if traces is None else traces["delivery"] + min_z = None + if trace is None: + ladder.fail("grasped", "invalid_case") + else: + ladder.max_tracking_error_rad = trace.max_tracking_error_rad + min_z = traces["height"].min_position + dropped = dropped_below_support(min_z, SUPPORT_SURFACE_Z) + # The handing arm holds the object: it left the pose it was resting in. + ladder.record( + "grasped", + traces["travel"].max_position > ENGAGED_MIN_DISPLACEMENT, + "object_not_grasped", + ) + # The object ended up on the receiving arm's side of the two hands, and + # stayed above the support plane while doing so. + ladder.record( + "transferred", + traces["hand_balance"].min_position < 0.0 and not dropped, + "object_dropped" if dropped else "task_goal_miss", + ) + left_open, left_close = get_hand_open_close_qpos( + robot, hand_control_part="left_hand", close_qpos=HAND_CLOSE_QPOS + ) + # The handing arm let go and the object is still held, not on the floor. + ladder.record( + "handed_over", + hand_is_released(robot, "left_hand", left_open, left_close) and not dropped, + "object_dropped" if dropped else "release_failure", + ) + ladder.record( + "placed", + trace.settled_position <= HANDOVER_DELIVERY_TOLERANCE_M, + "task_goal_miss", + ) video_path = "" - if should_record_case(args, recorded_count, ladder.task_success): + if should_record_case(args, recorded_count, ladder.success): reset_robot(robot, initial_qpos) reset_rigid_object(obj, initial_object_pose) recorded = replay_trajectory_with_recording( @@ -301,7 +358,7 @@ def _release_simulation(sim) -> None: def _case_result( case: HandOverCase, repeat: int, - ladder: SuccessLadder, + ladder: StageLadder, elapsed: float, mem_delta: dict[str, float] | None, trace, @@ -330,7 +387,7 @@ def _case_result( "trajectory_waypoints": ( int(traj.shape[1]) if traj is not None and traj.ndim >= 3 else 0 ), - "success": ladder.task_success, + "success": ladder.success, "video_path": video_path, } @@ -354,14 +411,13 @@ def _build_rows(results: list[dict[str, object]]): "trajectory_waypoints": result["trajectory_waypoints"], } ) - ladder: SuccessLadder = result["ladder"] # type: ignore[assignment] + ladder: StageLadder = result["ladder"] # type: ignore[assignment] metric_rows.append( { "sample_size": 1, "impl": "hand_over", "case_id": result["case_id"], "hand_over_case": result["hand_over_case"], - "success_rate": f"{float(ladder.task_success):.6f}", **ladder.as_row_fields(), "max_tracking_error_rad": format_float( result["max_tracking_error_rad"], 4 @@ -512,12 +568,16 @@ def run_all_benchmarks(args: argparse.Namespace | None = None) -> Path: report_results.append(result) if result["video_path"]: video_paths.append(str(result["video_path"])) - ladder: SuccessLadder = result["ladder"] # type: ignore[assignment] + ladder: StageLadder = result["ladder"] # type: ignore[assignment] + stages = " ".join( + f"{stage}={'ok' if ladder.passed[stage] else 'FAIL'}" + for stage in ladder.stages + if stage in ladder.passed + ) print( f" {result['case_id']:<26} " f"time={result['cost_time_ms']:>9.2f} ms | " - f"plan={ladder.planning_success} valid={ladder.motion_valid} " - f"exec={ladder.execution_success} task={ladder.task_success} " + f"{stages} " f"dist={format_float(result['final_delivery_distance_m'], 4)} " f"min_z={format_float(result['min_object_z_m'], 3)} " f"[{ladder.failure_reason or 'ok'}]" @@ -528,7 +588,7 @@ def run_all_benchmarks(args: argparse.Namespace | None = None) -> Path: _release_simulation(sim) perf_rows, metric_rows = _build_rows(report_results) - leaderboard_rows = build_ladder_leaderboard("hand_over", report_results) + leaderboard_rows = build_stage_leaderboard("hand_over", report_results) report_path = write_markdown_report( benchmark_name="atomic_action_hand_over", perf_rows=perf_rows, @@ -540,10 +600,18 @@ def run_all_benchmarks(args: argparse.Namespace | None = None) -> Path: f"Grasp samples: {n_sample}", f"Dual-arm robot: {DUAL_ROBOT_TYPE}, source=left_arm/left_hand, " "destination=right_arm/right_hand.", - "task_success requires the object to settle within " + "Stages: grasped (the object left the pose it rested in), " + "transferred (it ended on the receiving arm's side of the two " + "hands without falling), handed_over (the handing hand ended " + "nearer its open command than its closed one, object still up), " + "placed (it settled within " f"{HANDOVER_DELIVERY_TOLERANCE_M:.3f} m of the commanded delivery " - f"pose {FINAL_OBJECT_XYZ} and never to fall below " - f"{HANDOVER_DROP_Z_M:.3f} m during the transfer.", + f"pose {FINAL_OBJECT_XYZ}).", + f"The delivery budget is {HANDOVER_CONTACT_PHASES} x " + "TASK_POSITION_TOLERANCE_M, the derived budget the standard gives " + "a skill that breaks and re-establishes contact.", + "motion_valid and max_tracking_error_rad are diagnostics; neither " + "fails a stage.", "object_dropped uses the minimum object height observed across the " "whole replay, so a mid-transfer drop is caught even if the object " "later comes to rest near the target.", diff --git a/scripts/benchmark/atomic_action/move_end_effector_benchmark.py b/scripts/benchmark/atomic_action/move_end_effector_benchmark.py index b1d1dea97..d0f1926f5 100644 --- a/scripts/benchmark/atomic_action/move_end_effector_benchmark.py +++ b/scripts/benchmark/atomic_action/move_end_effector_benchmark.py @@ -30,11 +30,15 @@ from scripts.benchmark.atomic_action.common import ( CPU_MEMORY_BACKEND, add_common_benchmark_args, - build_single_action_leaderboard, + PRIMITIVE_POSITION_TOLERANCE_M, + SKILL_STAGES, + StageLadder, + build_stage_leaderboard, build_video_output_path, ensure_repo_root, ensure_torch, format_float, + replay_and_track_scalar, replay_trajectory_with_recording, reset_robot, resolve_profile, @@ -61,7 +65,10 @@ class PoseCase: DEFAULT_POSE_CASES = tuple(POSE_CASES.keys()) MOVE_SAMPLE_INTERVAL = 80 # Endpoint accuracy of the planned trajectory against the commanded pose. -SUCCESS_TOLERANCE_M = 0.01 +# The end-effector error is read from the robot after a physical replay and a +# terminal settle. Reading the planned trajectory's last waypoint reports +# 0.00002 m here, which describes the solver rather than the robot. +SETTLE_STEPS = 60 def add_benchmark_args(parser: argparse.ArgumentParser) -> None: @@ -163,32 +170,56 @@ def _run_case( ) reset_robot(robot, initial_qpos) + ladder = StageLadder(stages=SKILL_STAGES["move_end_effector"]) final_error_m = None - if is_success and traj.shape[1] > 0: - arm_joint_ids = robot.get_joint_ids(name="arm") - final_arm_qpos = traj[:, -1, arm_joint_ids] - final_pose = robot.compute_fk(qpos=final_arm_qpos, name="arm", to_matrix=True)[ - 0 - ] - final_error_m = float(torch.linalg.norm(final_pose[:3, 3] - target_pose[:3, 3])) - target_reached = bool( - is_success - and final_error_m is not None - and final_error_m <= SUCCESS_TOLERANCE_M - ) + if not is_success or traj.shape[1] == 0: + ladder.fail("reached", "planner_reported_failure") + else: + + def read_translation_error(waypoint_index: int) -> float: + del waypoint_index + achieved = robot.compute_fk( + qpos=robot.get_qpos(name="arm", target=False), + name="arm", + to_matrix=True, + )[0] + return float(torch.linalg.norm(achieved[:3, 3] - target_pose[:3, 3])) + + reset_robot(robot, initial_qpos) + trace = replay_and_track_scalar( + sim=sim, + robot=robot, + traj=traj, + read_value=read_translation_error, + hold_steps=SETTLE_STEPS, + ) + if trace is None: + ladder.fail("reached", "invalid_case") + else: + # MoveEndEffector binds only a motion endpoint, so it is scored + # after the terminal settle with the drive converged. + final_error_m = trace.settled_position + ladder.max_tracking_error_rad = trace.max_tracking_error_rad + ladder.record( + "reached", + final_error_m <= PRIMITIVE_POSITION_TOLERANCE_M, + "task_goal_miss", + ) + reset_robot(robot, initial_qpos) return { "case_id": f"{pose_case.name}:r{repeat}", "target_case": pose_case.name, "repeat": repeat, + "ladder": ladder, "planning_success": bool(is_success), - "target_reached": target_reached, + "target_reached": ladder.success, "cost_time_ms": elapsed * 1000.0, "cpu_delta_mb": mem_delta["cpu_mb"], "gpu_delta_mb": mem_delta["gpu_mb"], "peak_gpu_mb": peak_gpu, "final_error_m": final_error_m, "trajectory_waypoints": int(traj.shape[1]) if traj.ndim >= 2 else 0, - "failure_reason": "" if target_reached else "target_not_reached", + "failure_reason": ladder.failure_reason, "video_path": str(video_path) if video_path is not None else "", } @@ -217,11 +248,9 @@ def _build_rows(results: list[dict[str, object]]): "impl": "move_end_effector", "case_id": result["case_id"], "target_case": result["target_case"], - "success_rate": f"{float(result['target_reached']):.6f}", - "planning_success_rate": f"{float(result['planning_success']):.6f}", + **result["ladder"].as_row_fields(), "translation_err_m": format_float(result["final_error_m"]), "trajectory_waypoints": result["trajectory_waypoints"], - "failure_reason": result["failure_reason"] or "N/A", } ) return perf_rows, metric_rows @@ -297,7 +326,7 @@ def run_all_benchmarks(args: argparse.Namespace | None = None) -> Path: ) perf_rows, metric_rows = _build_rows(results) - leaderboard_rows = build_single_action_leaderboard("move_end_effector", metric_rows) + leaderboard_rows = build_stage_leaderboard("move_end_effector", results) report_path = write_markdown_report( benchmark_name="atomic_action_move_end_effector", perf_rows=perf_rows, @@ -306,7 +335,10 @@ def run_all_benchmarks(args: argparse.Namespace | None = None) -> Path: notes=[ f"Profile: {profile}", f"CPU memory backend: {CPU_MEMORY_BACKEND}", - f"Success tolerance: {SUCCESS_TOLERANCE_M} m translation error.", + "Stage: reached (the end-effector translation error after a " + f"physical replay and a {SETTLE_STEPS}-iteration settle is within " + f"{PRIMITIVE_POSITION_TOLERANCE_M} m). The error is measured on the " + "robot, not on the planned trajectory's last waypoint.", "Replay videos: " + (", ".join(video_paths) if video_paths else "disabled"), ], ) diff --git a/scripts/benchmark/atomic_action/move_held_object_benchmark.py b/scripts/benchmark/atomic_action/move_held_object_benchmark.py index c71a7ee93..4369afcfc 100644 --- a/scripts/benchmark/atomic_action/move_held_object_benchmark.py +++ b/scripts/benchmark/atomic_action/move_held_object_benchmark.py @@ -33,7 +33,11 @@ add_grasp_benchmark_args, add_object_position_benchmark_args, add_pickup_approach_benchmark_args, - build_single_action_leaderboard, + SKILL_STAGES, + StageLadder, + TASK_POSITION_TOLERANCE_M, + TASK_ROTATION_TOLERANCE_RAD, + build_stage_leaderboard, build_video_output_path, create_antipodal_object_semantics, create_benchmark_grasp_pose_generator, @@ -46,7 +50,6 @@ MeshObjectPreset, object_position_tuple, park_rigid_object, - PHYSICAL_MOVE_HELD_OBJECT_XYZ_TOLERANCE_M, PHYSICAL_PICK_MIN_LIFT_M, pickup_approach_direction_tuple, PositionCase, @@ -360,6 +363,7 @@ def _run_case( held_object_xy_error_m = None held_object_z_error_m = None held_object_xyz_error_m = None + held_object_rotation_error_rad = None if ( getattr(precondition_traj, "ndim", 0) >= 3 @@ -404,6 +408,14 @@ def _on_validation_step(waypoint_index: int) -> None: held_object_xy_error_m = xy_distance_m(final_obj_position, case.xyz) held_object_z_error_m = abs(final_obj_position[2] - case.xyz[2]) held_object_xyz_error_m = xyz_distance_m(final_obj_position, case.xyz) + # The goal commands an orientation as well as a position, so + # the transported stage scores both. + achieved_rotation = obj.get_local_pose(to_matrix=True)[0, :3, :3] + relative = target_pose[:3, :3].transpose(0, 1) @ achieved_rotation + cosine = (torch.diagonal(relative).sum() - 1.0) / 2.0 + held_object_rotation_error_rad = float( + torch.arccos(torch.clamp(cosine, min=-1.0, max=1.0)) + ) reset_robot(robot, initial_qpos) reset_rigid_object(obj, initial_obj_pose) @@ -412,25 +424,23 @@ def _on_validation_step(waypoint_index: int) -> None: object_lift_delta_m is not None and object_lift_delta_m >= PHYSICAL_PICK_MIN_LIFT_M ) - physical_move_success = bool( - still_held - and physical_pick_success - and held_object_xyz_error_m is not None - and held_object_xyz_error_m <= PHYSICAL_MOVE_HELD_OBJECT_XYZ_TOLERANCE_M - ) - success = physical_move_success - if success: - failure_reason = "" - elif not is_success: - failure_reason = "planning_failed" + ladder = StageLadder(stages=SKILL_STAGES["move_held_object"]) + if not is_success: + ladder.fail("transported", "planner_reported_failure") elif not still_held: - failure_reason = "held_object_lost" - elif not physical_pick_success: - failure_reason = "physical_pick_failed" - elif held_object_xyz_error_m is None: - failure_reason = "physical_replay_missing" + ladder.fail("transported", "object_dropped") else: - failure_reason = "held_object_target_miss" + ladder.record( + "transported", + held_object_xyz_error_m is not None + and held_object_xyz_error_m <= TASK_POSITION_TOLERANCE_M + and held_object_rotation_error_rad is not None + and held_object_rotation_error_rad <= TASK_ROTATION_TOLERANCE_RAD, + "task_goal_miss", + ) + physical_move_success = ladder.success + success = physical_move_success + failure_reason = ladder.failure_reason video_path = None if should_record_case(args, recorded_count, success): @@ -516,6 +526,8 @@ def _on_step(waypoint_index: int) -> None: "held_object_xy_error_m": held_object_xy_error_m, "held_object_z_error_m": held_object_z_error_m, "held_object_xyz_error_m": held_object_xyz_error_m, + "held_object_rotation_error_rad": held_object_rotation_error_rad, + "ladder": ladder, "precondition_waypoints": precondition_waypoints, "trajectory_waypoints": int(traj.shape[1]) if traj.ndim >= 2 else 0, "failure_reason": failure_reason, @@ -576,6 +588,10 @@ def _on_step(waypoint_index: int) -> None: "held_object_xy_error_m": None, "held_object_z_error_m": None, "held_object_xyz_error_m": None, + "held_object_rotation_error_rad": None, + "ladder": StageLadder(stages=SKILL_STAGES["move_held_object"]).fail( + "transported", "planner_exception" + ), "precondition_waypoints": 0, "trajectory_waypoints": 0, "failure_reason": f"exception:{type(exc).__name__}:{exc}", @@ -620,8 +636,7 @@ def _build_rows(results: list[dict[str, object]]): "pickup_approach": result["pickup_approach"], "approach_direction": result["approach_direction"], "held_object_case": result["held_object_case"], - "success_rate": f"{float(result['success']):.6f}", - "planning_success_rate": f"{float(result['planning_success']):.6f}", + **result["ladder"].as_row_fields(), "held_object_rate": f"{float(result['still_held']):.6f}", "physical_pick_success_rate": ( f"{float(result['physical_pick_success']):.6f}" @@ -640,6 +655,9 @@ def _build_rows(results: list[dict[str, object]]): "held_object_xyz_error_m": format_float( result["held_object_xyz_error_m"] ), + "held_object_rotation_error_rad": format_float( + result["held_object_rotation_error_rad"], 4 + ), "precondition_waypoints": result["precondition_waypoints"], "trajectory_waypoints": result["trajectory_waypoints"], "failure_reason": result["failure_reason"] or "N/A", @@ -742,7 +760,7 @@ def run_all_benchmarks(args: argparse.Namespace | None = None) -> Path: ) perf_rows, metric_rows = _build_rows(results) - leaderboard_rows = build_single_action_leaderboard("move_held_object", metric_rows) + leaderboard_rows = build_stage_leaderboard("move_held_object", results) report_path = write_markdown_report( benchmark_name="atomic_action_move_held_object", perf_rows=perf_rows, @@ -769,10 +787,16 @@ def run_all_benchmarks(args: argparse.Namespace | None = None) -> Path: f"abs(opening_axis_z) * {SIDE_GRASP_OPEN_AXIS_Z_COST_WEIGHT:.2f}." ), ( - "Physical success rule: PickUp precondition lift delta >= " - f"{PHYSICAL_PICK_MIN_LIFT_M:.3f} m, held-object state is kept, " - "and final object XYZ error <= " - f"{PHYSICAL_MOVE_HELD_OBJECT_XYZ_TOLERANCE_M:.3f} m." + "Stage: transported (the held-object state is kept, and the " + "object's final pose is within " + f"{TASK_POSITION_TOLERANCE_M:.3f} m and " + f"{TASK_ROTATION_TOLERANCE_RAD:.3f} rad of the commanded pose)." + ), + ( + "The PickUp precondition lift delta >= " + f"{PHYSICAL_PICK_MIN_LIFT_M:.3f} m is reported as " + "physical_pick_success_rate; it describes the precondition, " + "not MoveHeldObject." ), *summarize_video_recording(args, results, video_paths), ], diff --git a/scripts/benchmark/atomic_action/move_joints_benchmark.py b/scripts/benchmark/atomic_action/move_joints_benchmark.py index 6b0774429..4b60a5f95 100644 --- a/scripts/benchmark/atomic_action/move_joints_benchmark.py +++ b/scripts/benchmark/atomic_action/move_joints_benchmark.py @@ -29,12 +29,16 @@ from scripts.benchmark.atomic_action.common import ( CPU_MEMORY_BACKEND, + PRIMITIVE_ROTATION_TOLERANCE_RAD, + SKILL_STAGES, + StageLadder, add_common_benchmark_args, - build_single_action_leaderboard, + build_stage_leaderboard, build_video_output_path, ensure_repo_root, ensure_torch, format_float, + replay_and_track_scalar, replay_trajectory_with_recording, reset_robot, resolve_profile, @@ -69,7 +73,10 @@ class JointSequenceCase: } DEFAULT_SEQUENCE_CASES = tuple(SEQUENCE_CASES.keys()) MOVE_JOINTS_SAMPLE_INTERVAL = 80 -SUCCESS_TOLERANCE_RAD = 1e-4 +# Joint error is read from the robot after a physical replay and a terminal +# settle, not from the planned trajectory's last waypoint: that reports +# 0.000000 rad here, which describes the solver rather than the robot. +SETTLE_STEPS = 60 def add_benchmark_args(parser: argparse.ArgumentParser) -> None: @@ -156,6 +163,7 @@ def _run_case( atomic_engine.initial_context(control_dt=sim.sim_config.physics_dt), ) ) + ladder = StageLadder(stages=SKILL_STAGES["move_joints"]) is_success = bool(result.plan_success.all().item()) traj = result.trajectory.positions video_path = None @@ -173,29 +181,54 @@ def _run_case( reset_robot(robot, initial_qpos) final_error_rad = None - if is_success and traj.shape[1] > 0: + if not is_success: + ladder.fail("reached", "planner_reported_failure") + elif traj.shape[1] == 0: + ladder.fail("reached", "planner_reported_failure") + else: arm_joint_ids = robot.get_joint_ids(name="arm") - final_qpos = traj[:, -1, arm_joint_ids][0] expected = _qpos(JOINT_TARGETS[case.sequence[-1]], sim.device) - final_error_rad = float(torch.linalg.norm(final_qpos - expected)) - target_reached = bool( - is_success - and final_error_rad is not None - and final_error_rad <= SUCCESS_TOLERANCE_RAD - ) + + def read_joint_error(waypoint_index: int) -> float: + del waypoint_index + achieved = robot.get_qpos(name="arm", target=False)[0] + return float(torch.linalg.norm(achieved - expected)) + + reset_robot(robot, initial_qpos) + trace = replay_and_track_scalar( + sim=sim, + robot=robot, + traj=traj, + read_value=read_joint_error, + hold_steps=SETTLE_STEPS, + ) + if trace is None: + ladder.fail("reached", "invalid_case") + else: + # MoveJoints binds only a motion endpoint, so it is scored after + # the terminal settle with the drive converged. + final_error_rad = trace.settled_position + ladder.max_tracking_error_rad = trace.max_tracking_error_rad + ladder.record( + "reached", + final_error_rad <= PRIMITIVE_ROTATION_TOLERANCE_RAD, + "task_goal_miss", + ) + reset_robot(robot, initial_qpos) return { "case_id": f"{case.name}:r{repeat}", "sequence_case": case.name, "repeat": repeat, + "ladder": ladder, "planning_success": bool(is_success), - "target_reached": target_reached, + "target_reached": ladder.success, "cost_time_ms": elapsed * 1000.0, "cpu_delta_mb": mem_delta["cpu_mb"], "gpu_delta_mb": mem_delta["gpu_mb"], "peak_gpu_mb": peak_gpu, "final_error_rad": final_error_rad, "trajectory_waypoints": int(traj.shape[1]) if traj.ndim >= 2 else 0, - "failure_reason": "" if target_reached else "target_not_reached", + "failure_reason": ladder.failure_reason, "video_path": str(video_path) if video_path is not None else "", } @@ -224,11 +257,9 @@ def _build_rows(results: list[dict[str, object]]): "impl": "move_joints", "case_id": result["case_id"], "sequence_case": result["sequence_case"], - "success_rate": f"{float(result['target_reached']):.6f}", - "planning_success_rate": f"{float(result['planning_success']):.6f}", + **result["ladder"].as_row_fields(), "joint_err_rad": format_float(result["final_error_rad"]), "trajectory_waypoints": result["trajectory_waypoints"], - "failure_reason": result["failure_reason"] or "N/A", } ) return perf_rows, metric_rows @@ -310,7 +341,7 @@ def run_all_benchmarks(args: argparse.Namespace | None = None) -> Path: ) perf_rows, metric_rows = _build_rows(results) - leaderboard_rows = build_single_action_leaderboard("move_joints", metric_rows) + leaderboard_rows = build_stage_leaderboard("move_joints", results) report_path = write_markdown_report( benchmark_name="atomic_action_move_joints", perf_rows=perf_rows, @@ -319,7 +350,10 @@ def run_all_benchmarks(args: argparse.Namespace | None = None) -> Path: notes=[ f"Profile: {profile}", f"CPU memory backend: {CPU_MEMORY_BACKEND}", - f"Success tolerance: {SUCCESS_TOLERANCE_RAD} rad joint error.", + "Stage: reached (the arm joint error after a physical replay and " + f"a {SETTLE_STEPS}-iteration settle is within " + f"{PRIMITIVE_ROTATION_TOLERANCE_RAD} rad). The error is measured on " + "the robot, not on the planned trajectory's last waypoint.", "Replay videos: " + (", ".join(video_paths) if video_paths else "disabled"), ], ) diff --git a/scripts/benchmark/atomic_action/open_door_benchmark.py b/scripts/benchmark/atomic_action/open_door_benchmark.py index 6a7fe366d..988c54356 100644 --- a/scripts/benchmark/atomic_action/open_door_benchmark.py +++ b/scripts/benchmark/atomic_action/open_door_benchmark.py @@ -16,10 +16,10 @@ """Benchmark OpenDoor atomic action across microwave hinge opening targets. -Reports the four-level success ladder (planning_success, motion_valid, -execution_success, task_success) defined in -``scripts/benchmark/motion_generation/BENCHMARK_DESIGN.md``. Task success -requires the physically replayed hinge to reach the commanded opening angle. +Reports the ordered stages defined for OpenDoor in ``BENCHMARK_STANDARD.md``: +grasped, opened, released. The hinge is scored in physics at the end of the +commanded door-actuation segment; plan validity and replay tracking are +reported as diagnostics and never fail the skill. Run: embodichain benchmark atomic-action --action open_door """ @@ -33,13 +33,16 @@ from scripts.benchmark.atomic_action.common import ( CPU_MEMORY_BACKEND, DEFAULT_REPLAY_STEPS_PER_WAYPOINT, - REPLAY_TRACKING_TOLERANCE_RAD, - SuccessLadder, + ENGAGED_MIN_DISPLACEMENT, + SKILL_STAGES, + StageLadder, + TASK_ROTATION_TOLERANCE_RAD, add_common_benchmark_args, add_grasp_benchmark_args, - build_ladder_leaderboard, + build_stage_leaderboard, build_video_output_path, check_motion_valid, + hand_is_released, ensure_repo_root, ensure_torch, format_float, @@ -74,9 +77,6 @@ class DoorCase: HAND_INTERP_STEPS = 12 DOOR_WAYPOINT_COUNT = 50 REPLAY_HOLD_STEPS = 60 -# Measured hinge error of a healthy replay is ~0.02 rad; 0.15 rad (8.6 deg) -# keeps that comfortably inside while still rejecting a door that never moved. -HINGE_SUCCESS_TOLERANCE_RAD = 0.15 def add_benchmark_args(parser: argparse.ArgumentParser) -> None: @@ -193,8 +193,12 @@ def _run_case( args: argparse.Namespace, recorded_count: int, ) -> dict[str, object]: - """Run one OpenDoor case through the full four-level success ladder.""" - ladder = SuccessLadder() + """Run one OpenDoor case through its stages.""" + from scripts.tutorials.atomic_action.tutorial_utils import ( + get_hand_open_close_qpos, + ) + + ladder = StageLadder(stages=SKILL_STAGES["open_door"]) _reset_scene(robot, microwave, initial_qpos, initial_hinge_qpos) sim.update(step=4) @@ -208,21 +212,21 @@ def _run_case( ) except Exception as exc: print(f" planner exception: {type(exc).__name__}: {exc}") - ladder.fail("planning_success", "planner_exception") + ladder.fail("grasped", "planner_exception") return _case_result(case, repeat, ladder, 0.0, None, None, open_angle, "") - ladder.planning_success = bool(result.plan_success.all().item()) traj = result.trajectory.positions - if not ladder.planning_success: - ladder.fail("planning_success", "planner_reported_failure") + if not bool(result.plan_success.all().item()): + ladder.fail("grasped", "planner_reported_failure") return _case_result( case, repeat, ladder, elapsed, mem_delta, None, open_angle, "", peak_gpu ) - motion_valid, motion_reason = check_motion_valid(traj, robot) + motion_valid, motion_detail = check_motion_valid(traj, robot) ladder.motion_valid = motion_valid - if not motion_valid: - ladder.fail("motion_valid", motion_reason) + ladder.motion_detail = motion_detail + if motion_detail == "non_finite_trajectory": + ladder.fail("grasped", "planner_reported_failure") return _case_result( case, repeat, @@ -250,38 +254,46 @@ def _run_case( articulation=microwave, joint_index=hinge_joint_index, measure_waypoint=measure_waypoint, - # This door swing needs a slower replay than the shared default. - # At 16 the arm trails the plan by 0.66 rad and the tracking - # gate rejects the run; 64 keeps it inside the gate in both - # profiles and lands the hinge within 0.001 rad of the command. + # This door swing is replayed slower than the shared default. + # The rate is a measurement configuration, not a criterion: at 16 + # steps the arm trails the plan by 0.66 rad, so the hinge reading + # would describe the drive, and at 64 it lands within 0.001 rad of + # the command in both profiles. steps_per_waypoint=4 * DEFAULT_REPLAY_STEPS_PER_WAYPOINT, hold_steps=REPLAY_HOLD_STEPS, ) - ladder.execution_success = trace is not None except Exception as exc: print(f" replay exception: {type(exc).__name__}: {exc}") trace = None - ladder.fail("execution_success", "controller_tracking_failure") - - if trace is not None and ( - trace.max_tracking_error_rad > REPLAY_TRACKING_TOLERANCE_RAD - ): - # The arm did not follow the plan, so the hinge reading describes the - # drive rather than OpenDoor. Do not score task success. - ladder.execution_success = False - ladder.fail("execution_success", "controller_tracking_failure") - elif ladder.execution_success and trace is not None: + + if trace is None: + ladder.fail("grasped", "invalid_case") + else: + ladder.max_tracking_error_rad = trace.max_tracking_error_rad + # A hinge cannot move unless the hand held its handle, so the hinge + # having left its initial angle is the scene evidence that the grasp + # took. A door that never moved failed to be grasped; one that moved + # but stopped short missed the goal, and the two have different owners. + ladder.record( + "grasped", + abs(trace.measured_displacement) > ENGAGED_MIN_DISPLACEMENT, + "object_not_grasped", + ) # The hinge target is an absolute angle, so compare the replayed # absolute position rather than the displacement. hinge_error = abs(trace.measured_position - open_angle) - ladder.task_success = hinge_error <= HINGE_SUCCESS_TOLERANCE_RAD - if not ladder.task_success: - ladder.fail("task_success", "task_goal_miss") - elif not ladder.failure_stage: - ladder.fail("execution_success", "controller_tracking_failure") + ladder.record( + "opened", hinge_error <= TASK_ROTATION_TOLERANCE_RAD, "task_goal_miss" + ) + hand_open, hand_close = get_hand_open_close_qpos(robot) + ladder.record( + "released", + hand_is_released(robot, "hand", hand_open, hand_close), + "release_failure", + ) video_path = "" - if should_record_case(args, recorded_count, ladder.task_success): + if should_record_case(args, recorded_count, ladder.success): _reset_scene(robot, microwave, initial_qpos, initial_hinge_qpos) recorded = replay_trajectory_with_recording( sim=sim, @@ -311,7 +323,7 @@ def _run_case( def _case_result( case: DoorCase, repeat: int, - ladder: SuccessLadder, + ladder: StageLadder, elapsed: float, mem_delta: dict[str, float] | None, trace, @@ -344,7 +356,7 @@ def _case_result( "trajectory_waypoints": ( int(traj.shape[1]) if traj is not None and traj.ndim >= 3 else 0 ), - "success": ladder.task_success, + "success": ladder.success, "video_path": video_path, } @@ -368,19 +380,15 @@ def _build_rows(results: list[dict[str, object]]): "trajectory_waypoints": result["trajectory_waypoints"], } ) - ladder: SuccessLadder = result["ladder"] # type: ignore[assignment] + ladder: StageLadder = result["ladder"] # type: ignore[assignment] metric_rows.append( { "sample_size": 1, "impl": "open_door", "case_id": result["case_id"], "door_case": result["door_case"], - "success_rate": f"{float(ladder.task_success):.6f}", **ladder.as_row_fields(), "target_angle_rad": format_float(result["target_angle_rad"], 4), - "max_tracking_error_rad": format_float( - result["max_tracking_error_rad"], 4 - ), "hinge_initial_rad": format_float(result["hinge_initial_rad"], 4), "hinge_measured_rad": format_float(result["hinge_measured_rad"], 4), "hinge_peak_signed_rad": format_float( @@ -484,18 +492,23 @@ def run_all_benchmarks(args: argparse.Namespace | None = None) -> Path: results.append(result) if result["video_path"]: video_paths.append(str(result["video_path"])) - ladder: SuccessLadder = result["ladder"] # type: ignore[assignment] + ladder: StageLadder = result["ladder"] # type: ignore[assignment] + stages = " ".join( + f"{stage}={'ok' if ladder.passed[stage] else 'FAIL'}" + for stage in ladder.stages + if stage in ladder.passed + ) print( f" {result['case_id']:<20} " f"time={result['cost_time_ms']:>9.2f} ms | " - f"plan={ladder.planning_success} valid={ladder.motion_valid} " - f"exec={ladder.execution_success} task={ladder.task_success} " + f"{stages} " f"hinge_err={format_float(result['hinge_error_rad'], 4)} " + f"track={format_float(ladder.max_tracking_error_rad, 4)} " f"[{ladder.failure_reason or 'ok'}]" ) perf_rows, metric_rows = _build_rows(results) - leaderboard_rows = build_ladder_leaderboard("open_door", results) + leaderboard_rows = build_stage_leaderboard("open_door", results) report_path = write_markdown_report( benchmark_name="atomic_action_open_door", perf_rows=perf_rows, @@ -505,10 +518,14 @@ def run_all_benchmarks(args: argparse.Namespace | None = None) -> Path: f"Profile: {profile}", f"CPU memory backend: {CPU_MEMORY_BACKEND}", f"Grasp samples: {n_sample}", - "task_success requires the physically replayed hinge angle to reach " - f"the commanded target within {HINGE_SUCCESS_TOLERANCE_RAD} rad, " - "measured at the last waypoint of the commanded door-actuation " - "('open') segment.", + "Stages: grasped (the hinge left its initial angle), opened (the " + "replayed hinge angle reached the commanded target within " + f"{TASK_ROTATION_TOLERANCE_RAD} rad), released (the hand ended " + "nearer its open command than its closed one). The hinge is scored " + "at the last waypoint of the commanded door-actuation ('open') " + "segment.", + "motion_valid and max_tracking_error_rad are diagnostics; neither " + "fails a stage.", "hinge_settled_rad is the resting angle after release and retract. " "This microwave hinge is undriven (drive_type='none'), so it swings " "freely once released; that value is diagnostic, not a success gate.", diff --git a/scripts/benchmark/atomic_action/pickup_benchmark.py b/scripts/benchmark/atomic_action/pickup_benchmark.py index eaecbe45b..aa5886e75 100644 --- a/scripts/benchmark/atomic_action/pickup_benchmark.py +++ b/scripts/benchmark/atomic_action/pickup_benchmark.py @@ -32,7 +32,9 @@ add_grasp_benchmark_args, add_object_position_benchmark_args, add_pickup_approach_benchmark_args, - build_single_action_leaderboard, + SKILL_STAGES, + StageLadder, + build_stage_leaderboard, build_video_output_path, create_antipodal_object_semantics, create_benchmark_grasp_pose_generator, @@ -46,6 +48,7 @@ object_position_tuple, park_rigid_object, PHYSICAL_PICK_MIN_LIFT_M, + dropped_below_support, pickup_approach_direction_tuple, PositionCase, record_static_scene_video, @@ -213,6 +216,8 @@ def _run_case( final_obj_position = None object_lift_delta_m = None object_xy_drift_m = None + replay_min_z_m = None + replay_final_waypoint_z_m = None if bool(is_success) and getattr(traj, "ndim", 0) >= 3 and traj.shape[1] > 0: reset_robot(robot, initial_qpos) @@ -223,12 +228,19 @@ def _run_case( def _on_validation_step(waypoint_index: int) -> None: nonlocal should_clear_object_dynamics + nonlocal replay_min_z_m + nonlocal replay_final_waypoint_z_m if ( should_clear_object_dynamics and waypoint_index + 1 >= post_grasp_clear_step ): obj.clear_dynamics() should_clear_object_dynamics = False + height = object_position_tuple(obj)[2] + replay_final_waypoint_z_m = height + replay_min_z_m = ( + height if replay_min_z_m is None else min(replay_min_z_m, height) + ) final_obj_position = replay_trajectory_for_physical_validation( sim=sim, @@ -249,11 +261,35 @@ def _on_validation_step(waypoint_index: int) -> None: held_created = bool( is_success and final_state.get_held_object("arm") is not None ) - physical_pick_success = bool( - held_created - and object_lift_delta_m is not None - and object_lift_delta_m >= PHYSICAL_PICK_MIN_LIFT_M - ) + ladder = StageLadder(stages=SKILL_STAGES["pick_up"]) + if not is_success: + ladder.fail("grasped", "planner_reported_failure") + else: + # The skill's own held-object state is the grasp measurement: it is + # what every later stage is asserted against. + ladder.record("grasped", held_created, "object_not_grasped") + lift_at_segment_end_m = None + if replay_final_waypoint_z_m is not None: + lift_at_segment_end_m = ( + replay_final_waypoint_z_m - initial_obj_position[2] + ) + ladder.record( + "lifted", + lift_at_segment_end_m is not None + and lift_at_segment_end_m >= PHYSICAL_PICK_MIN_LIFT_M, + "task_goal_miss", + ) + dropped = replay_min_z_m is not None and dropped_below_support( + replay_min_z_m, initial_obj_position[2] + ) + ladder.record( + "held", + (not dropped) + and object_lift_delta_m is not None + and object_lift_delta_m >= PHYSICAL_PICK_MIN_LIFT_M, + "object_dropped" if dropped else "task_goal_miss", + ) + physical_pick_success = ladder.success video_path = None if should_record_case(args, recorded_count, physical_pick_success): @@ -313,16 +349,7 @@ def _on_step(waypoint_index: int) -> None: )[0] lift_height_m = float(final_pose[2, 3] - start_pose[2, 3]) success = physical_pick_success - if success: - failure_reason = "" - elif not is_success: - failure_reason = "planning_failed" - elif not held_created: - failure_reason = "held_object_missing" - elif object_lift_delta_m is None: - failure_reason = "physical_replay_missing" - else: - failure_reason = "physical_pick_lift_too_low" + failure_reason = ladder.failure_reason return { "case_id": case_id, "object_type": object_preset.object_type, @@ -333,6 +360,7 @@ def _on_step(waypoint_index: int) -> None: "approach": approach, "approach_direction": approach_direction_text, "repeat": repeat, + "ladder": ladder, "planning_success": bool(is_success), "held_created": held_created, "physical_pick_success": physical_pick_success, @@ -389,6 +417,9 @@ def _on_step(waypoint_index: int) -> None: "approach": approach, "approach_direction": "N/A", "repeat": repeat, + "ladder": StageLadder(stages=SKILL_STAGES["pick_up"]).fail( + "grasped", "planner_exception" + ), "planning_success": False, "held_created": False, "physical_pick_success": False, @@ -443,19 +474,13 @@ def _build_rows(results: list[dict[str, object]]): "position_case": result["position_case"], "approach": result["approach"], "approach_direction": result["approach_direction"], - "success_rate": f"{float(result['success']):.6f}", - "planning_success_rate": f"{float(result['planning_success']):.6f}", - "held_object_rate": f"{float(result['held_created']):.6f}", - "physical_pick_success_rate": ( - f"{float(result['physical_pick_success']):.6f}" - ), + **result["ladder"].as_row_fields(), "lift_height_m": format_float(result["lift_height_m"]), "object_initial_z_m": format_float(result["object_initial_z_m"]), "object_final_z_m": format_float(result["object_final_z_m"]), "object_lift_delta_m": format_float(result["object_lift_delta_m"]), "object_xy_drift_m": format_float(result["object_xy_drift_m"]), "trajectory_waypoints": result["trajectory_waypoints"], - "failure_reason": result["failure_reason"] or "N/A", } ) return perf_rows, metric_rows @@ -552,7 +577,7 @@ def run_all_benchmarks(args: argparse.Namespace | None = None) -> Path: ) perf_rows, metric_rows = _build_rows(results) - leaderboard_rows = build_single_action_leaderboard("pick_up", metric_rows) + leaderboard_rows = build_stage_leaderboard("pick_up", results) report_path = write_markdown_report( benchmark_name="atomic_action_pick_up", perf_rows=perf_rows, diff --git a/scripts/benchmark/atomic_action/place_benchmark.py b/scripts/benchmark/atomic_action/place_benchmark.py index 37419eaa9..7a7b483eb 100644 --- a/scripts/benchmark/atomic_action/place_benchmark.py +++ b/scripts/benchmark/atomic_action/place_benchmark.py @@ -33,7 +33,11 @@ add_grasp_benchmark_args, add_object_position_benchmark_args, add_pickup_approach_benchmark_args, - build_single_action_leaderboard, + SKILL_STAGES, + StageLadder, + TASK_POSITION_TOLERANCE_M, + TASK_ROTATION_TOLERANCE_RAD, + build_stage_leaderboard, build_video_output_path, create_antipodal_object_semantics, create_benchmark_grasp_pose_generator, @@ -47,7 +51,6 @@ object_position_tuple, park_rigid_object, PHYSICAL_PICK_MIN_LIFT_M, - PHYSICAL_PLACE_XY_TOLERANCE_M, pickup_approach_direction_tuple, PositionCase, record_static_scene_video, @@ -346,6 +349,9 @@ def _run_case( object_lift_delta_m = None place_xy_error_m = None place_z_error_m = None + segment_end_pose = None + settle_drift_m = None + settle_drift_rad = None if ( getattr(precondition_traj, "ndim", 0) >= 3 @@ -375,6 +381,10 @@ def _on_validation_step(waypoint_index: int) -> None: and waypoint_index + 1 >= precondition_waypoints ): precondition_obj_position = object_position_tuple(obj) + # The last waypoint is where Place stops acting on the object; + # everything after it is the terminal settle. + nonlocal segment_end_pose + segment_end_pose = obj.get_local_pose(to_matrix=True)[0].clone() final_obj_position = replay_trajectory_for_physical_validation( sim=sim, @@ -390,6 +400,18 @@ def _on_validation_step(waypoint_index: int) -> None: if bool(is_success) and final_obj_position is not None: place_xy_error_m = xy_distance_m(final_obj_position, case.xyz) place_z_error_m = abs(final_obj_position[2] - case.xyz[2]) + if segment_end_pose is not None: + settled_pose = obj.get_local_pose(to_matrix=True)[0] + settle_drift_m = float( + torch.linalg.norm(settled_pose[:3, 3] - segment_end_pose[:3, 3]) + ) + relative = ( + segment_end_pose[:3, :3].transpose(0, 1) @ settled_pose[:3, :3] + ) + cosine = (torch.diagonal(relative).sum() - 1.0) / 2.0 + settle_drift_rad = float( + torch.arccos(torch.clamp(cosine, min=-1.0, max=1.0)) + ) reset_robot(robot, initial_qpos) reset_rigid_object(obj, initial_obj_pose) @@ -398,25 +420,31 @@ def _on_validation_step(waypoint_index: int) -> None: object_lift_delta_m is not None and object_lift_delta_m >= PHYSICAL_PICK_MIN_LIFT_M ) - physical_place_success = bool( - released - and physical_pick_success - and place_xy_error_m is not None - and place_xy_error_m <= PHYSICAL_PLACE_XY_TOLERANCE_M - ) - success = physical_place_success - if success: - failure_reason = "" - elif not is_success: - failure_reason = "planning_failed" - elif not released: - failure_reason = "held_object_not_released" - elif not physical_pick_success: - failure_reason = "physical_pick_failed" - elif place_xy_error_m is None: - failure_reason = "physical_replay_missing" + ladder = StageLadder(stages=SKILL_STAGES["place"]) + if not is_success: + ladder.fail("released", "planner_reported_failure") else: - failure_reason = "place_target_miss" + ladder.record("released", released, "release_failure") + ladder.record( + "placed", + place_xy_error_m is not None + and place_xy_error_m <= TASK_POSITION_TOLERANCE_M, + "task_goal_miss", + ) + # Drift between the end of the placing segment and the settled + # pose: an object that lands on the target and then topples or + # rolls off it was not placed. + ladder.record( + "stable", + settle_drift_m is not None + and settle_drift_m <= TASK_POSITION_TOLERANCE_M + and settle_drift_rad is not None + and settle_drift_rad <= TASK_ROTATION_TOLERANCE_RAD, + "task_goal_miss", + ) + physical_place_success = ladder.success + success = physical_place_success + failure_reason = ladder.failure_reason video_path = None if should_record_case(args, recorded_count, success): @@ -482,6 +510,7 @@ def _on_step(waypoint_index: int) -> None: "place_case": case.name, "repeat": repeat, "planning_success": bool(is_success), + "ladder": ladder, "released": released, "physical_pick_success": physical_pick_success, "physical_place_success": physical_place_success, @@ -547,6 +576,9 @@ def _on_step(waypoint_index: int) -> None: "place_case": case.name, "repeat": repeat, "planning_success": False, + "ladder": StageLadder(stages=SKILL_STAGES["place"]).fail( + "released", "planner_exception" + ), "released": False, "physical_pick_success": False, "physical_place_success": False, @@ -605,15 +637,10 @@ def _build_rows(results: list[dict[str, object]]): "pickup_approach": result["pickup_approach"], "approach_direction": result["approach_direction"], "place_case": result["place_case"], - "success_rate": f"{float(result['success']):.6f}", - "planning_success_rate": f"{float(result['planning_success']):.6f}", - "release_success_rate": f"{float(result['released']):.6f}", + **result["ladder"].as_row_fields(), "physical_pick_success_rate": ( f"{float(result['physical_pick_success']):.6f}" ), - "physical_place_success_rate": ( - f"{float(result['physical_place_success']):.6f}" - ), "object_lift_delta_m": format_float(result["object_lift_delta_m"]), "object_final_x_m": format_float(result["object_final_x_m"]), "object_final_y_m": format_float(result["object_final_y_m"]), @@ -722,7 +749,7 @@ def run_all_benchmarks(args: argparse.Namespace | None = None) -> Path: ) perf_rows, metric_rows = _build_rows(results) - leaderboard_rows = build_single_action_leaderboard("place", metric_rows) + leaderboard_rows = build_stage_leaderboard("place", results) report_path = write_markdown_report( benchmark_name="atomic_action_place", perf_rows=perf_rows, @@ -749,9 +776,19 @@ def run_all_benchmarks(args: argparse.Namespace | None = None) -> Path: f"abs(opening_axis_z) * {SIDE_GRASP_OPEN_AXIS_Z_COST_WEIGHT:.2f}." ), ( - "Physical success rule: PickUp precondition lift delta >= " - f"{PHYSICAL_PICK_MIN_LIFT_M:.3f} m, Place released the object, " - f"and final object XY error <= {PHYSICAL_PLACE_XY_TOLERANCE_M:.3f} m." + "Stages: released (the skill's held-object state is empty), " + "placed (the object's final XY error is within " + f"{TASK_POSITION_TOLERANCE_M:.3f} m of the commanded pose), " + "stable (it drifts no more than " + f"{TASK_POSITION_TOLERANCE_M:.3f} m and " + f"{TASK_ROTATION_TOLERANCE_RAD:.3f} rad between the end of the " + "placing segment and the settled pose)." + ), + ( + "The PickUp precondition lift delta >= " + f"{PHYSICAL_PICK_MIN_LIFT_M:.3f} m is reported as " + "physical_pick_success_rate; it describes the precondition, " + "not Place." ), *summarize_video_recording(args, results, video_paths), ], diff --git a/scripts/benchmark/atomic_action/pour_benchmark.py b/scripts/benchmark/atomic_action/pour_benchmark.py index 869595ddc..6d4ed7415 100644 --- a/scripts/benchmark/atomic_action/pour_benchmark.py +++ b/scripts/benchmark/atomic_action/pour_benchmark.py @@ -16,10 +16,10 @@ """Benchmark Pour atomic action as a PickUp-then-Pour sequence. -Reports the four-level success ladder (planning_success, motion_valid, -execution_success, task_success). Pour rotates a held object about its own -internal axis, so task success is measured as the signed rotation the object -actually turned about that axis during the commanded pour segment. +Reports the ordered stage defined for Pour in ``BENCHMARK_STANDARD.md``: +poured. Pour rotates a held object about its own internal axis and returns it, +so the stage is measured as the peak signed rotation the object actually +turned about that axis during the commanded pour segment. Run: embodichain benchmark atomic-action --action pour """ @@ -33,11 +33,13 @@ from scripts.benchmark.atomic_action.common import ( CPU_MEMORY_BACKEND, DEFAULT_REPLAY_STEPS_PER_WAYPOINT, - REPLAY_TRACKING_TOLERANCE_RAD, - SuccessLadder, + ENGAGED_MIN_DISPLACEMENT, + SKILL_STAGES, + StageLadder, + TASK_ROTATION_TOLERANCE_RAD, add_common_benchmark_args, add_grasp_benchmark_args, - build_ladder_leaderboard, + build_stage_leaderboard, build_video_output_path, check_motion_valid, ensure_repo_root, @@ -66,7 +68,6 @@ REPLAY_HOLD_STEPS = 60 # Commanded-versus-achieved pour rotation tolerance. Uses the same angular # tolerance as the other rotation skills; see BENCHMARK_STANDARD.md. -POUR_TOLERANCE_RAD = 0.15 @dataclass(frozen=True) @@ -202,9 +203,9 @@ def _run_case( args: argparse.Namespace, recorded_count: int, ) -> dict[str, object]: - """Run one Pour case through the full four-level success ladder.""" + """Run one Pour case through its stages.""" torch = ensure_torch() - ladder = SuccessLadder() + ladder = StageLadder(stages=SKILL_STAGES["pour"]) reset_robot(robot, initial_qpos) reset_rigid_object(obj, initial_object_pose) sim.update(step=10) @@ -218,25 +219,26 @@ def _run_case( ) except Exception as exc: print(f" planner exception: {type(exc).__name__}: {exc}") - ladder.fail("planning_success", "planner_exception") + ladder.fail("poured", "planner_exception") return _case_result(case, repeat, ladder, 0.0, None, None, "") - ladder.planning_success = bool(result.plan_success.all().item()) + plan_success = bool(result.plan_success.all().item()) traj = result.trajectory.positions - if not ladder.planning_success: + if not plan_success: for plan in result.action_plans: if not plan.plan_success.all(): messages = plan.diagnostics.messages or ("planning failed",) print(f" plan failure [{plan.skill_id}]: {'; '.join(messages)}") - ladder.fail("planning_success", "planner_reported_failure") + ladder.fail("poured", "planner_reported_failure") return _case_result( case, repeat, ladder, elapsed, mem_delta, None, "", peak_gpu ) - motion_valid, motion_reason = check_motion_valid(traj, robot) + motion_valid, motion_detail = check_motion_valid(traj, robot) ladder.motion_valid = motion_valid - if not motion_valid: - ladder.fail("motion_valid", motion_reason) + ladder.motion_detail = motion_detail + if motion_detail == "non_finite_trajectory": + ladder.fail("poured", "planner_reported_failure") return _case_result( case, repeat, ladder, elapsed, mem_delta, None, "", peak_gpu, traj ) @@ -274,19 +276,15 @@ def read_pour_rotation(waypoint_index: int) -> float: steps_per_waypoint=DEFAULT_REPLAY_STEPS_PER_WAYPOINT, hold_steps=REPLAY_HOLD_STEPS, ) - ladder.execution_success = trace is not None except Exception as exc: print(f" replay exception: {type(exc).__name__}: {exc}") trace = None - ladder.fail("execution_success", "controller_tracking_failure") rotation_error = None - if trace is not None and ( - trace.max_tracking_error_rad > REPLAY_TRACKING_TOLERANCE_RAD - ): - ladder.execution_success = False - ladder.fail("execution_success", "controller_tracking_failure") - elif ladder.execution_success and trace is not None: + if trace is None: + ladder.fail("poured", "invalid_case") + else: + ladder.max_tracking_error_rad = trace.max_tracking_error_rad # Pour rotates to the poured pose and then returns to the pose it # started from, all inside one "pour" segment, so the end of the # segment is back at zero rotation by construction. The achieved pour @@ -294,14 +292,14 @@ def read_pour_rotation(waypoint_index: int) -> float: rotation_error = abs( abs(trace.peak_signed_displacement) - abs(case.rotate_angle_rad) ) - ladder.task_success = rotation_error <= POUR_TOLERANCE_RAD - if not ladder.task_success: - ladder.fail("task_success", "task_goal_miss") - elif not ladder.failure_stage: - ladder.fail("execution_success", "controller_tracking_failure") + ladder.record( + "poured", + rotation_error <= TASK_ROTATION_TOLERANCE_RAD, + "task_goal_miss", + ) video_path = "" - if should_record_case(args, recorded_count, ladder.task_success): + if should_record_case(args, recorded_count, ladder.success): reset_robot(robot, initial_qpos) reset_rigid_object(obj, initial_object_pose) recorded = replay_trajectory_with_recording( @@ -334,7 +332,7 @@ def read_pour_rotation(waypoint_index: int) -> float: def _case_result( case: PourCase, repeat: int, - ladder: SuccessLadder, + ladder: StageLadder, elapsed: float, mem_delta: dict[str, float] | None, trace, @@ -367,7 +365,7 @@ def _case_result( "trajectory_waypoints": ( int(traj.shape[1]) if traj is not None and traj.ndim >= 3 else 0 ), - "success": ladder.task_success, + "success": ladder.success, "video_path": video_path, } @@ -391,14 +389,13 @@ def _build_rows(results: list[dict[str, object]]): "trajectory_waypoints": result["trajectory_waypoints"], } ) - ladder: SuccessLadder = result["ladder"] # type: ignore[assignment] + ladder: StageLadder = result["ladder"] # type: ignore[assignment] metric_rows.append( { "sample_size": 1, "impl": "pour", "case_id": result["case_id"], "pour_case": result["pour_case"], - "success_rate": f"{float(ladder.task_success):.6f}", **ladder.as_row_fields(), "commanded_rotation_rad": format_float( result["commanded_rotation_rad"], 4 @@ -515,12 +512,16 @@ def run_all_benchmarks(args: argparse.Namespace | None = None) -> Path: results.append(result) if result["video_path"]: video_paths.append(str(result["video_path"])) - ladder: SuccessLadder = result["ladder"] # type: ignore[assignment] + ladder: StageLadder = result["ladder"] # type: ignore[assignment] + stages = " ".join( + f"{stage}={'ok' if ladder.passed[stage] else 'FAIL'}" + for stage in ladder.stages + if stage in ladder.passed + ) print( f" {result['case_id']:<22} " f"time={result['cost_time_ms']:>9.2f} ms | " - f"plan={ladder.planning_success} valid={ladder.motion_valid} " - f"exec={ladder.execution_success} task={ladder.task_success} " + f"{stages} " f"peak={format_float(result['peak_rotation_rad'], 4)} " f"z0={format_float(result['pour_start_object_z_m'], 3)} " f"err={format_float(result['rotation_error_rad'], 4)} " @@ -528,7 +529,7 @@ def run_all_benchmarks(args: argparse.Namespace | None = None) -> Path: ) perf_rows, metric_rows = _build_rows(results) - leaderboard_rows = build_ladder_leaderboard("pour", results) + leaderboard_rows = build_stage_leaderboard("pour", results) report_path = write_markdown_report( benchmark_name="atomic_action_pour", perf_rows=perf_rows, @@ -542,7 +543,7 @@ def run_all_benchmarks(args: argparse.Namespace | None = None) -> Path: "covers both invocations.", "task_success requires the peak signed rotation of the object " "about its own internal axis during the commanded 'pour' segment " - f"to match the commanded angle within {POUR_TOLERANCE_RAD:.3f} rad.", + f"to match the commanded angle within {TASK_ROTATION_TOLERANCE_RAD:.3f} rad.", "Pour rotates to the poured pose and returns to its starting pose " "inside a single 'pour' segment, so the end-of-segment rotation is " "zero by construction; the peak is the achieved pour.", diff --git a/scripts/benchmark/atomic_action/press_benchmark.py b/scripts/benchmark/atomic_action/press_benchmark.py index a51a3dbdd..150f33aff 100644 --- a/scripts/benchmark/atomic_action/press_benchmark.py +++ b/scripts/benchmark/atomic_action/press_benchmark.py @@ -16,10 +16,10 @@ """Benchmark Press atomic action on the microwave start button. -Reports the four-level success ladder (planning_success, motion_valid, -execution_success, task_success). Task success requires the button's prismatic -joint to reach a configured fraction of its stroke during the commanded press -segment; the design protocol allows the button to rebound afterwards. +Reports the ordered stages defined for Press in ``BENCHMARK_STANDARD.md``: +contacted, pressed. The button's prismatic joint must reach the configured +fraction of its stroke during the commanded press segment; the peak signed +displacement is used, so a button that rebounds afterwards still counts. Run: embodichain benchmark atomic-action --action press """ @@ -32,10 +32,12 @@ from scripts.benchmark.atomic_action.common import ( CPU_MEMORY_BACKEND, DEFAULT_REPLAY_STEPS_PER_WAYPOINT, - REPLAY_TRACKING_TOLERANCE_RAD, - SuccessLadder, + ENGAGED_MIN_DISPLACEMENT, + PHYSICAL_PRESS_MIN_STROKE_RATIO, + SKILL_STAGES, + StageLadder, add_common_benchmark_args, - build_ladder_leaderboard, + build_stage_leaderboard, build_video_output_path, check_motion_valid, ensure_repo_root, @@ -60,7 +62,6 @@ # The microwave button joint is a 6 mm prismatic stroke. Requiring 80% of the # asset's own usable stroke keeps the gate asset-relative instead of hard-coding # a millimetre figure that would not transfer to another button. -PRESS_MIN_STROKE_FRACTION = 0.8 @dataclass(frozen=True) @@ -167,15 +168,15 @@ def _run_case( args: argparse.Namespace, recorded_count: int, ) -> dict[str, object]: - """Run one Press case through the full four-level success ladder.""" - ladder = SuccessLadder() + """Run one Press case through its stages.""" + ladder = StageLadder(stages=SKILL_STAGES["press"]) _reset_scene(robot, microwave, initial_qpos, initial_button_qpos) sim.update(step=4) invocation, context = _build_invocation( atomic_engine, microwave, case, sim.sim_config.physics_dt ) - required_travel_m = PRESS_MIN_STROKE_FRACTION * button_stroke_m + required_travel_m = PHYSICAL_PRESS_MIN_STROKE_RATIO * button_stroke_m try: elapsed, mem_delta, peak_gpu, result = timed_call( @@ -183,15 +184,15 @@ def _run_case( ) except Exception as exc: print(f" planner exception: {type(exc).__name__}: {exc}") - ladder.fail("planning_success", "planner_exception") + ladder.fail("contacted", "planner_exception") return _case_result( case, repeat, ladder, 0.0, None, None, required_travel_m, "" ) - ladder.planning_success = bool(result.plan_success.all().item()) + plan_success = bool(result.plan_success.all().item()) traj = result.trajectory.positions - if not ladder.planning_success: - ladder.fail("planning_success", "planner_reported_failure") + if not plan_success: + ladder.fail("contacted", "planner_reported_failure") return _case_result( case, repeat, @@ -204,10 +205,11 @@ def _run_case( peak_gpu, ) - motion_valid, motion_reason = check_motion_valid(traj, robot) + motion_valid, motion_detail = check_motion_valid(traj, robot) ladder.motion_valid = motion_valid - if not motion_valid: - ladder.fail("motion_valid", motion_reason) + ladder.motion_detail = motion_detail + if motion_detail == "non_finite_trajectory": + ladder.fail("contacted", "planner_reported_failure") return _case_result( case, repeat, @@ -232,31 +234,29 @@ def _run_case( steps_per_waypoint=DEFAULT_REPLAY_STEPS_PER_WAYPOINT, hold_steps=REPLAY_HOLD_STEPS, ) - ladder.execution_success = trace is not None except Exception as exc: print(f" replay exception: {type(exc).__name__}: {exc}") trace = None - ladder.fail("execution_success", "controller_tracking_failure") - - if trace is not None and ( - trace.max_tracking_error_rad > REPLAY_TRACKING_TOLERANCE_RAD - ): - # The arm did not follow the plan, so the button reading describes the - # drive rather than Press. Do not score task success. - ladder.execution_success = False - ladder.fail("execution_success", "controller_tracking_failure") - elif ladder.execution_success and trace is not None: + + if trace is None: + ladder.fail("contacted", "invalid_case") + else: + ladder.max_tracking_error_rad = trace.max_tracking_error_rad + ladder.record( + "contacted", + abs(trace.peak_signed_displacement) > ENGAGED_MIN_DISPLACEMENT, + "task_goal_miss", + ) # The button travels along +qpos; use the peak signed displacement so a - # button that reaches its stroke and rebounds still counts, exactly as - # the design protocol specifies for contact skills. - ladder.task_success = abs(trace.peak_signed_displacement) >= required_travel_m - if not ladder.task_success: - ladder.fail("task_success", "task_goal_miss") - elif not ladder.failure_stage: - ladder.fail("execution_success", "controller_tracking_failure") + # button that reaches its stroke and rebounds still counts. + ladder.record( + "pressed", + abs(trace.peak_signed_displacement) >= required_travel_m, + "task_goal_miss", + ) video_path = "" - if should_record_case(args, recorded_count, ladder.task_success): + if should_record_case(args, recorded_count, ladder.success): _reset_scene(robot, microwave, initial_qpos, initial_button_qpos) recorded = replay_trajectory_with_recording( sim=sim, @@ -286,7 +286,7 @@ def _run_case( def _case_result( case: PressCase, repeat: int, - ladder: SuccessLadder, + ladder: StageLadder, elapsed: float, mem_delta: dict[str, float] | None, trace, @@ -316,7 +316,7 @@ def _case_result( "trajectory_waypoints": ( int(traj.shape[1]) if traj is not None and traj.ndim >= 3 else 0 ), - "success": ladder.task_success, + "success": ladder.success, "video_path": video_path, } @@ -340,14 +340,13 @@ def _build_rows(results: list[dict[str, object]]): "trajectory_waypoints": result["trajectory_waypoints"], } ) - ladder: SuccessLadder = result["ladder"] # type: ignore[assignment] + ladder: StageLadder = result["ladder"] # type: ignore[assignment] metric_rows.append( { "sample_size": 1, "impl": "press", "case_id": result["case_id"], "press_case": result["press_case"], - "success_rate": f"{float(ladder.task_success):.6f}", **ladder.as_row_fields(), "commanded_press_m": format_float(result["commanded_press_m"], 4), "required_travel_m": format_float(result["required_travel_m"], 4), @@ -415,7 +414,7 @@ def run_all_benchmarks(args: argparse.Namespace | None = None) -> Path: button_stroke_m = float(button_limits[1] - button_limits[0]) print( f"Button joint {BUTTON_JOINT_NAME!r}: stroke={button_stroke_m * 1000.0:.2f} mm, " - f"required travel={PRESS_MIN_STROKE_FRACTION * button_stroke_m * 1000.0:.2f} mm" + f"required travel={PHYSICAL_PRESS_MIN_STROKE_RATIO * button_stroke_m * 1000.0:.2f} mm" ) atomic_engine = AtomicActionEngine( @@ -457,18 +456,23 @@ def run_all_benchmarks(args: argparse.Namespace | None = None) -> Path: results.append(result) if result["video_path"]: video_paths.append(str(result["video_path"])) - ladder: SuccessLadder = result["ladder"] # type: ignore[assignment] + ladder: StageLadder = result["ladder"] # type: ignore[assignment] + stages = " ".join( + f"{stage}={'ok' if ladder.passed[stage] else 'FAIL'}" + for stage in ladder.stages + if stage in ladder.passed + ) print( f" {result['case_id']:<22} " f"time={result['cost_time_ms']:>9.2f} ms | " - f"plan={ladder.planning_success} valid={ladder.motion_valid} " - f"exec={ladder.execution_success} task={ladder.task_success} " + f"{stages} " + f"track={format_float(ladder.max_tracking_error_rad, 4)} " f"peak={format_float(result['button_peak_signed_m'], 5)} " f"[{ladder.failure_reason or 'ok'}]" ) perf_rows, metric_rows = _build_rows(results) - leaderboard_rows = build_ladder_leaderboard("press", results) + leaderboard_rows = build_stage_leaderboard("press", results) report_path = write_markdown_report( benchmark_name="atomic_action_press", perf_rows=perf_rows, @@ -479,10 +483,16 @@ def run_all_benchmarks(args: argparse.Namespace | None = None) -> Path: f"CPU memory backend: {CPU_MEMORY_BACKEND}", f"Button joint: {BUTTON_JOINT_NAME}, usable stroke " f"{button_stroke_m * 1000.0:.2f} mm.", - "task_success requires the peak signed button displacement during " - f"replay to reach {PRESS_MIN_STROKE_FRACTION:.0%} of that stroke " - f"({PRESS_MIN_STROKE_FRACTION * button_stroke_m * 1000.0:.2f} mm). " - "Rebound after the press segment is allowed.", + "Stages: contacted (the button moved at all), pressed (the peak " + "signed button displacement during replay reached " + f"{PHYSICAL_PRESS_MIN_STROKE_RATIO:.0%} of that stroke, " + f"{PHYSICAL_PRESS_MIN_STROKE_RATIO * button_stroke_m * 1000.0:.2f} " + "mm). Rebound after the press segment is allowed.", + "This criterion only shows that the button moved: commanding 3 mm " + "still drives it to 5.66 mm of its 6 mm stroke, so the ratio does " + "not discriminate commanded depth on this asset.", + "motion_valid and max_tracking_error_rad are diagnostics; neither " + "fails a stage.", "Planning time excludes a discarded warm-up compile.", *summarize_video_recording(args, results, video_paths), ], diff --git a/scripts/benchmark/atomic_action/slide_benchmark.py b/scripts/benchmark/atomic_action/slide_benchmark.py index 81c65f440..dd4e172da 100644 --- a/scripts/benchmark/atomic_action/slide_benchmark.py +++ b/scripts/benchmark/atomic_action/slide_benchmark.py @@ -16,9 +16,9 @@ """Benchmark Slide atomic action on the drawer prismatic joint. -Reports the four-level success ladder (planning_success, motion_valid, -execution_success, task_success). Task success requires the drawer joint to -reach the commanded translation distance during the commanded pull segment. +Reports the ordered stages defined for Slide in ``BENCHMARK_STANDARD.md``: +grasped, slid, released. The drawer joint must reach the commanded translation +distance at the end of the commanded pull segment, and the hand must let go. Run: embodichain benchmark atomic-action --action slide """ @@ -30,14 +30,18 @@ from scripts.benchmark.atomic_action.common import ( CPU_MEMORY_BACKEND, - SuccessLadder, + ENGAGED_MIN_DISPLACEMENT, + SKILL_STAGES, + StageLadder, + TASK_POSITION_TOLERANCE_M, add_common_benchmark_args, add_grasp_benchmark_args, - build_ladder_leaderboard, + build_stage_leaderboard, build_video_output_path, ensure_repo_root, ensure_torch, format_float, + hand_is_released, replay_trajectory_with_recording, reset_robot, resolve_profile, @@ -53,9 +57,6 @@ HAND_INTERP_STEPS = 12 APPROACH_DISTANCE = 0.10 REPLAY_HOLD_STEPS = 60 -# Commanded-versus-achieved drawer travel tolerance. Calibrated from the -# measured pull error on this asset; see BENCHMARK_STANDARD.md. -SLIDE_TOLERANCE_M = 0.03 @dataclass(frozen=True) @@ -154,14 +155,13 @@ def _build_rows(results: list[dict[str, object]]): "trajectory_waypoints": result["trajectory_waypoints"], } ) - ladder: SuccessLadder = result["ladder"] # type: ignore[assignment] + ladder: StageLadder = result["ladder"] # type: ignore[assignment] metric_rows.append( { "sample_size": 1, "impl": "slide", "case_id": result["case_id"], "slide_case": result["slide_case"], - "success_rate": f"{float(ladder.task_success):.6f}", **ladder.as_row_fields(), "commanded_travel_m": format_float(result["commanded_travel_m"], 4), "max_tracking_error_rad": format_float( @@ -233,7 +233,7 @@ def run_all_benchmarks(args: argparse.Namespace | None = None) -> Path: drawer_stroke_m = float(drawer_limits[1] - drawer_limits[0]) print( f"Drawer joint {DRAWER_JOINT_NAME!r}: stroke={drawer_stroke_m:.3f} m, " - f"tolerance={SLIDE_TOLERANCE_M:.3f} m" + f"tolerance={TASK_POSITION_TOLERANCE_M:.3f} m" ) n_sample = 1000 if profile == "smoke" else args.n_sample @@ -277,11 +277,27 @@ def run_all_benchmarks(args: argparse.Namespace | None = None) -> Path: _reset_scene(robot, drawer, initial_qpos, initial_drawer_qpos) sim.update(step=4) - def evaluate(trace, case=case): + def evaluate(ladder, traces, case=case): + trace = traces["joint"] + # A drawer only moves while its handle is held, so the drawer + # having left its initial position is the scene evidence that + # the grasp took. + ladder.record( + "grasped", + abs(trace.measured_displacement) > ENGAGED_MIN_DISPLACEMENT, + "object_not_grasped", + ) error = abs( abs(trace.measured_displacement) - case.translation_distance_m ) - return (error <= SLIDE_TOLERANCE_M, "task_goal_miss") + ladder.record( + "slid", error <= TASK_POSITION_TOLERANCE_M, "task_goal_miss" + ) + ladder.record( + "released", + hand_is_released(robot, "hand", hand_open, hand_close), + "release_failure", + ) outcome = run_articulated_contact_case( sim=sim, @@ -293,6 +309,7 @@ def evaluate(trace, case=case): ), joint_index=drawer_joint_index, actuation_segment="pull", + stages=SKILL_STAGES["slide"], evaluate=evaluate, hold_steps=REPLAY_HOLD_STEPS, ) @@ -304,7 +321,7 @@ def evaluate(trace, case=case): ) video_path = "" - if should_record_case(args, len(video_paths), outcome.ladder.task_success): + if should_record_case(args, len(video_paths), outcome.ladder.success): _reset_scene(robot, drawer, initial_qpos, initial_drawer_qpos) recorded = replay_trajectory_with_recording( sim=sim, @@ -341,23 +358,27 @@ def evaluate(trace, case=case): "drawer_settled_disp_m": trace.settled_displacement if trace else None, "travel_error_m": travel_error, "trajectory_waypoints": outcome.trajectory_waypoints, - "success": outcome.ladder.task_success, + "success": outcome.ladder.success, "video_path": video_path, } results.append(result) ladder = outcome.ladder + stages = " ".join( + f"{stage}={'ok' if ladder.passed[stage] else 'FAIL'}" + for stage in ladder.stages + if stage in ladder.passed + ) print( f" {result['case_id']:<22} " f"time={result['cost_time_ms']:>9.2f} ms | " - f"plan={ladder.planning_success} valid={ladder.motion_valid} " - f"exec={ladder.execution_success} task={ladder.task_success} " + f"{stages} " f"travel={format_float(result['drawer_measured_disp_m'], 4)} " f"err={format_float(travel_error, 4)} " f"[{ladder.failure_reason or 'ok'}]" ) perf_rows, metric_rows = _build_rows(results) - leaderboard_rows = build_ladder_leaderboard("slide", results) + leaderboard_rows = build_stage_leaderboard("slide", results) report_path = write_markdown_report( benchmark_name="atomic_action_slide", perf_rows=perf_rows, @@ -369,12 +390,16 @@ def evaluate(trace, case=case): f"Grasp samples: {n_sample}", f"Drawer joint: {DRAWER_JOINT_NAME}, usable stroke " f"{drawer_stroke_m:.3f} m.", - "task_success requires the replayed drawer travel at the end of the " - "commanded 'pull' segment to match the commanded translation within " - f"{SLIDE_TOLERANCE_M:.3f} m.", + "Stages: grasped (the drawer left its initial position), slid " + "(the replayed travel at the end of the commanded 'pull' segment " + f"matched the command within {TASK_POSITION_TOLERANCE_M:.3f} m), " + "released (the hand ended nearer its open command than its closed " + "one).", "drawer_settled_disp_m is the resting travel after release and " "retract. The drawer joint is undriven (drive_type='none'), so that " - "value is diagnostic, not a success gate.", + "value is a diagnostic.", + "motion_valid and max_tracking_error_rad are diagnostics; neither " + "fails a stage.", "Planning time excludes a discarded warm-up compile and includes " "handle grasp-pose generation.", *summarize_video_recording(args, results, video_paths), diff --git a/scripts/benchmark/atomic_action/twist_benchmark.py b/scripts/benchmark/atomic_action/twist_benchmark.py index d268d96f6..cc9757ef8 100644 --- a/scripts/benchmark/atomic_action/twist_benchmark.py +++ b/scripts/benchmark/atomic_action/twist_benchmark.py @@ -16,9 +16,10 @@ """Benchmark Twist atomic action on the microwave power knob. -Reports the four-level success ladder (planning_success, motion_valid, -execution_success, task_success). Task success requires the knob's revolute -joint to reach the commanded twist angle during the commanded twist segment. +Reports the ordered stages defined for Twist in ``BENCHMARK_STANDARD.md``: +grasped, twisted, released. The twisted stage scores the executed end-effector +rotation about the knob axis; the knob joint's own rotation is a diagnostic, +because this knob offers almost no resistance and over-rotates. Run: embodichain benchmark atomic-action --action twist """ @@ -31,13 +32,17 @@ from scripts.benchmark.atomic_action.common import ( CPU_MEMORY_BACKEND, - SuccessLadder, + ENGAGED_MIN_DISPLACEMENT, + SKILL_STAGES, + StageLadder, + TASK_ROTATION_TOLERANCE_RAD, add_common_benchmark_args, - build_ladder_leaderboard, + build_stage_leaderboard, build_video_output_path, ensure_repo_root, ensure_torch, format_float, + hand_is_released, replay_trajectory_with_recording, reset_robot, resolve_profile, @@ -53,9 +58,101 @@ HAND_INTERP_STEPS = 12 PRE_GRASP_DISTANCE = 0.12 REPLAY_HOLD_STEPS = 60 -# Commanded-versus-achieved knob rotation tolerance. Calibrated from the -# measured twist error on this asset; see BENCHMARK_STANDARD.md. -TWIST_TOLERANCE_RAD = 0.15 +# Angle used to read the knob joint's axis off the asset itself. +AXIS_PROBE_ANGLE_RAD = 0.05 + + +def _knob_axis_world(sim, microwave, joint_index: int): + """Return the knob joint's rotation axis in world coordinates. + + The axis is measured from the asset rather than assumed: the knob joint is + perturbed by a small angle, the resulting change in the knob link's + orientation is a rotation about that joint's axis, and its direction is + read back out of that rotation. The joint is restored afterwards. + + Args: + sim: Simulation manager, stepped so the probe reaches the link pose. + microwave: Articulation owning the knob joint. + joint_index: Index of the knob joint. + + Returns: + Unit axis in world coordinates with shape ``(3,)``. + """ + from scripts.tutorials.atomic_action.twist import KNOB_LINK_NAME + + torch = ensure_torch() + initial_qpos = microwave.get_qpos(target=False).clone() + before = microwave.get_link_pose(KNOB_LINK_NAME, to_matrix=True)[0, :3, :3].clone() + probed = initial_qpos.clone() + probed[:, joint_index] = probed[:, joint_index] + AXIS_PROBE_ANGLE_RAD + microwave.set_qpos(probed, target=False) + microwave.set_qpos(probed, target=True) + sim.update(step=1) + after = microwave.get_link_pose(KNOB_LINK_NAME, to_matrix=True)[0, :3, :3].clone() + microwave.set_qpos(initial_qpos, target=False) + microwave.set_qpos(initial_qpos, target=True) + microwave.clear_dynamics() + sim.update(step=1) + + relative = before.transpose(0, 1) @ after + axis = torch.stack( + [ + relative[2, 1] - relative[1, 2], + relative[0, 2] - relative[2, 0], + relative[1, 0] - relative[0, 1], + ] + ) + norm = torch.linalg.vector_norm(axis) + if float(norm) <= 1.0e-6: + raise RuntimeError("The knob joint did not rotate its link; axis unknown.") + return before @ (axis / norm) + + +def _make_twist_rotation_reader(robot, axis_world, reference_waypoint: int): + """Return a reader for the end-effector rotation about the knob axis. + + The standard scores Twist on what the hand did, not on where the knob + ended: this knob offers almost no resistance, so the gripper wedges against + it and drags it past the command. The reference orientation is captured at + the start of the twist segment, after the approach has already reoriented + the hand. + + Args: + robot: Robot whose arm pose is read. + axis_world: Unit knob axis in world coordinates. + reference_waypoint: Waypoint index at which the reference orientation + is captured. + + Returns: + Callable returning the signed rotation in radians, zero until the + reference is captured. + """ + torch = ensure_torch() + state: dict[str, object] = {} + + def perpendicular_component(rotation): + for column in (0, 1): + direction = rotation[:, column] + projected = direction - axis_world * torch.dot(direction, axis_world) + norm = torch.linalg.vector_norm(projected) + if float(norm) > 1.0e-6: + return projected / norm + raise RuntimeError("The end-effector frame is degenerate about the knob axis.") + + def read(waypoint_index: int) -> float: + pose = robot.compute_fk(robot.get_qpos(name="arm"), name="arm", to_matrix=True) + current = perpendicular_component(pose[0, :3, :3]) + if waypoint_index <= reference_waypoint: + state["reference"] = current + return 0.0 + reference = state.get("reference") + if reference is None: + return 0.0 + sine = torch.dot(axis_world, torch.linalg.cross(reference, current)) + cosine = torch.dot(reference, current) + return float(torch.atan2(sine, cosine)) + + return read @dataclass(frozen=True) @@ -152,14 +249,13 @@ def _build_rows(results: list[dict[str, object]]): "trajectory_waypoints": result["trajectory_waypoints"], } ) - ladder: SuccessLadder = result["ladder"] # type: ignore[assignment] + ladder: StageLadder = result["ladder"] # type: ignore[assignment] metric_rows.append( { "sample_size": 1, "impl": "twist", "case_id": result["case_id"], "twist_case": result["twist_case"], - "success_rate": f"{float(ladder.task_success):.6f}", **ladder.as_row_fields(), "commanded_angle_rad": format_float(result["commanded_angle_rad"], 4), "max_tracking_error_rad": format_float( @@ -173,6 +269,7 @@ def _build_rows(results: list[dict[str, object]]): "knob_settled_disp_rad": format_float( result["knob_settled_disp_rad"], 4 ), + "ee_rotation_rad": format_float(result["ee_rotation_rad"], 4), "angle_error_rad": format_float(result["angle_error_rad"], 4), } ) @@ -223,9 +320,10 @@ def run_all_benchmarks(args: argparse.Namespace | None = None) -> Path: hand_open, hand_close = get_hand_open_close_qpos(robot) knob_joint_index = microwave.joint_names.index(KNOB_JOINT_NAME) + knob_axis_world = _knob_axis_world(sim, microwave, knob_joint_index) print( f"Knob joint {KNOB_JOINT_NAME!r} (link cap_1), " - f"tolerance={TWIST_TOLERANCE_RAD:.3f} rad" + f"tolerance={TASK_ROTATION_TOLERANCE_RAD:.3f} rad" ) atomic_engine = AtomicActionEngine( @@ -252,11 +350,37 @@ def run_all_benchmarks(args: argparse.Namespace | None = None) -> Path: _reset_scene(robot, microwave, initial_qpos, initial_knob_qpos) sim.update(step=4) - def evaluate(trace, case=case): - error = abs( - abs(trace.measured_displacement) - abs(case.twist_angle_rad) + def build_readers(compiled): + try: + reference = compiled.segment(0, "twist").start - 1 + except (KeyError, AttributeError, IndexError): + reference = -1 + return { + "ee_rotation": _make_twist_rotation_reader( + robot, knob_axis_world, reference + ) + } + + def evaluate(ladder, traces, case=case): + knob = traces["joint"] + rotation = traces["ee_rotation"] + # A knob cannot turn unless the hand is on it, so the knob + # leaving its initial angle is the evidence that the grasp + # took. Where the knob ended is then only a diagnostic. + ladder.record( + "grasped", + abs(knob.measured_displacement) > ENGAGED_MIN_DISPLACEMENT, + "object_not_grasped", + ) + error = abs(abs(rotation.measured_position) - abs(case.twist_angle_rad)) + ladder.record( + "twisted", error <= TASK_ROTATION_TOLERANCE_RAD, "task_goal_miss" + ) + ladder.record( + "released", + hand_is_released(robot, "hand", hand_open, hand_close), + "release_failure", ) - return (error <= TWIST_TOLERANCE_RAD, "task_goal_miss") outcome = run_articulated_contact_case( sim=sim, @@ -268,18 +392,21 @@ def evaluate(trace, case=case): ), joint_index=knob_joint_index, actuation_segment="twist", + stages=SKILL_STAGES["twist"], evaluate=evaluate, + build_extra_readers=build_readers, hold_steps=REPLAY_HOLD_STEPS, ) trace = outcome.trace + rotation = outcome.channels.get("ee_rotation") angle_error = None - if trace is not None: + if rotation is not None: angle_error = abs( - abs(trace.measured_displacement) - abs(case.twist_angle_rad) + abs(rotation.measured_position) - abs(case.twist_angle_rad) ) video_path = "" - if should_record_case(args, len(video_paths), outcome.ladder.task_success): + if should_record_case(args, len(video_paths), outcome.ladder.success): _reset_scene(robot, microwave, initial_qpos, initial_knob_qpos) recorded = replay_trajectory_with_recording( sim=sim, @@ -314,25 +441,33 @@ def evaluate(trace, case=case): trace.peak_signed_displacement if trace else None ), "knob_settled_disp_rad": trace.settled_displacement if trace else None, + "ee_rotation_rad": ( + rotation.measured_position if rotation is not None else None + ), "angle_error_rad": angle_error, "trajectory_waypoints": outcome.trajectory_waypoints, - "success": outcome.ladder.task_success, + "success": outcome.ladder.success, "video_path": video_path, } results.append(result) ladder = outcome.ladder + stages = " ".join( + f"{stage}={'ok' if ladder.passed[stage] else 'FAIL'}" + for stage in ladder.stages + if stage in ladder.passed + ) print( f" {result['case_id']:<22} " f"time={result['cost_time_ms']:>9.2f} ms | " - f"plan={ladder.planning_success} valid={ladder.motion_valid} " - f"exec={ladder.execution_success} task={ladder.task_success} " + f"{stages} " f"rot={format_float(result['knob_measured_disp_rad'], 4)} " + f"ee_rot={format_float(result['ee_rotation_rad'], 4)} " f"err={format_float(angle_error, 4)} " f"[{ladder.failure_reason or 'ok'}]" ) perf_rows, metric_rows = _build_rows(results) - leaderboard_rows = build_ladder_leaderboard("twist", results) + leaderboard_rows = build_stage_leaderboard("twist", results) report_path = write_markdown_report( benchmark_name="atomic_action_twist", perf_rows=perf_rows, @@ -342,11 +477,19 @@ def evaluate(trace, case=case): f"Profile: {profile}", f"CPU memory backend: {CPU_MEMORY_BACKEND}", f"Knob joint: {KNOB_JOINT_NAME} (target link cap_1).", - "task_success requires the replayed knob rotation at the end of the " - "commanded 'twist' segment to match the commanded angle within " - f"{TWIST_TOLERANCE_RAD:.3f} rad.", - "knob_settled_disp_rad is the resting rotation after release and " - "retract; it is diagnostic, not a success gate.", + "Stages: grasped (the knob left its initial angle), twisted (the " + "executed end-effector rotation about the knob axis matched the " + f"command within {TASK_ROTATION_TOLERANCE_RAD:.3f} rad at the end " + "of the 'twist' segment), released (the hand ended nearer its open " + "command than its closed one).", + "The knob joint's own rotation is a diagnostic. It offers almost " + "no resistance, so the gripper wedges against it and drags it past " + "the command; knob_settled_disp_rad is the resting value after " + "release and retract.", + "The knob axis is measured from the asset by probing the joint, " + "not assumed from the affordance default.", + "motion_valid and max_tracking_error_rad are diagnostics; neither " + "fails a stage.", "Planning time excludes a discarded warm-up compile.", *summarize_video_recording(args, results, video_paths), ], diff --git a/tests/benchmark/atomic_action/__init__.py b/tests/benchmark/atomic_action/__init__.py new file mode 100644 index 000000000..d12bf7157 --- /dev/null +++ b/tests/benchmark/atomic_action/__init__.py @@ -0,0 +1,21 @@ +# ---------------------------------------------------------------------------- +# Copyright (c) 2021-2026 DexForce Technology Co., Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ---------------------------------------------------------------------------- + +"""Tests for the atomic-action benchmark suite.""" + +from __future__ import annotations + +__all__: list[str] = [] diff --git a/tests/benchmark/atomic_action/test_measurement_standard.py b/tests/benchmark/atomic_action/test_measurement_standard.py new file mode 100644 index 000000000..d4e0ee6ea --- /dev/null +++ b/tests/benchmark/atomic_action/test_measurement_standard.py @@ -0,0 +1,222 @@ +# ---------------------------------------------------------------------------- +# Copyright (c) 2021-2026 DexForce Technology Co., Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ---------------------------------------------------------------------------- + +"""Tests that the atomic-action measurement layer follows the standard. + +The criteria live in ``scripts/benchmark/atomic_action/BENCHMARK_STANDARD.md``. +These tests read that document and check the shipped code against it, so a +stage list, a failure reason, or a tolerance cannot drift from the text that +defines it. The rest covers the stage bookkeeping the benchmarks rely on. +""" + +from __future__ import annotations + +import re +from pathlib import Path + +import pytest + +from scripts.benchmark.atomic_action.common import ( + FAILURE_TAXONOMY, + PHYSICAL_DROP_MARGIN_M, + PRIMITIVE_POSITION_TOLERANCE_M, + PRIMITIVE_ROTATION_TOLERANCE_RAD, + SKILL_STAGES, + StageLadder, + TASK_POSITION_TOLERANCE_M, + TASK_ROTATION_TOLERANCE_RAD, + build_stage_leaderboard, + dropped_below_support, + hand_is_released, +) + +STANDARD_PATH = ( + Path(__file__).resolve().parents[3] + / "scripts" + / "benchmark" + / "atomic_action" + / "BENCHMARK_STANDARD.md" +) +CAMEL_BOUNDARY = re.compile(r"(? str: + """Return the measurement standard's text.""" + return STANDARD_PATH.read_text(encoding="utf-8") + + +def _documented_stages() -> dict[str, tuple[str, ...]]: + """Return the skill-to-stages table from section 1 of the standard.""" + stages: dict[str, tuple[str, ...]] = {} + for line in _standard_text().splitlines(): + columns = [column.strip() for column in line.strip().strip("|").split("|")] + if len(columns) != 4 or not columns[0].startswith("`"): + continue + if columns[1] not in ("`PRIMITIVE_*`", "`TASK_*`"): + continue + skill = CAMEL_BOUNDARY.sub("_", columns[0].strip("`")).lower() + stages[skill] = tuple(name.strip().strip("`") for name in columns[2].split("→")) + return stages + + +def _ladder(*recorded: tuple[str, bool]) -> StageLadder: + """Return a pick_up ladder with the given stage outcomes recorded.""" + ladder = StageLadder(stages=SKILL_STAGES["pick_up"]) + for stage, passed in recorded: + ladder.record(stage, passed, "" if passed else "task_goal_miss") + return ladder + + +def test_skill_stages_match_the_standard() -> None: + """Every skill's stages are exactly the ones section 1 lists, in order.""" + documented = _documented_stages() + assert documented, "No skill rows parsed from the standard." + assert SKILL_STAGES == documented + + +def test_failure_taxonomy_matches_the_standard() -> None: + """The failure reasons are exactly the task-level subset section 1 names.""" + reasons = re.search( + r"Reasons are the task-level subset\n(.*?)\n\n", _standard_text(), re.S + ) + assert reasons is not None + documented = set(re.findall(r"`([a-z_]+)`", reasons.group(1))) + assert set(FAILURE_TAXONOMY) == documented + + +def test_tolerances_match_the_standard() -> None: + """The tolerance constants carry the values section 0 publishes.""" + text = _standard_text() + for name, value in ( + ("PRIMITIVE_POSITION_TOLERANCE_M", PRIMITIVE_POSITION_TOLERANCE_M), + ("PRIMITIVE_ROTATION_TOLERANCE_RAD", PRIMITIVE_ROTATION_TOLERANCE_RAD), + ("TASK_POSITION_TOLERANCE_M", TASK_POSITION_TOLERANCE_M), + ("TASK_ROTATION_TOLERANCE_RAD", TASK_ROTATION_TOLERANCE_RAD), + ("PHYSICAL_DROP_MARGIN_M", PHYSICAL_DROP_MARGIN_M), + ): + match = re.search(rf"^{name}\s*=\s*([0-9.]+)", text, re.M) + assert match is not None, f"{name} is not published in the standard." + assert float(match.group(1)) == pytest.approx(value) + + +def test_a_stage_after_a_failure_is_not_reached() -> None: + """Recording past the first failure is ignored, so no later stage counts.""" + ladder = _ladder(("grasped", True), ("lifted", False)) + ladder.record("held", True) + + assert ladder.passed == {"grasped": True, "lifted": False} + assert ladder.failure_stage == "lifted" + assert ladder.failure_reason == "task_goal_miss" + assert not ladder.success + + +def test_a_closing_stage_fails_the_case() -> None: + """A skill that lifts an object and then drops it did not pick it up.""" + ladder = _ladder(("grasped", True), ("lifted", True), ("held", False)) + + assert not ladder.success + assert ladder.failure_stage == "held" + + +def test_stages_must_be_recorded_in_order() -> None: + """A stage recorded out of its declared order is a programming error.""" + ladder = StageLadder(stages=SKILL_STAGES["pick_up"]) + with pytest.raises(ValueError, match="out of order"): + ladder.record("lifted", True) + + +def test_a_failure_reason_must_come_from_the_taxonomy() -> None: + """A reason outside the taxonomy is rejected rather than reported.""" + ladder = StageLadder(stages=SKILL_STAGES["pick_up"]) + with pytest.raises(ValueError, match="Unknown failure reason"): + ladder.record("grasped", False, "controller_tracking_failure") + + +def test_an_unreached_stage_reports_not_applicable() -> None: + """An unreached stage is N/A, never zero, in the per-case row.""" + fields = _ladder(("grasped", False)).as_row_fields() + + assert fields["grasped"] == "0.000000" + assert fields["lifted"] == "N/A" + assert fields["held"] == "N/A" + assert fields["failure_reason"] == "task_goal_miss" + + +def test_diagnostics_never_decide_the_outcome() -> None: + """Plan validity and tracking error are carried, and change nothing.""" + ladder = _ladder(("grasped", True), ("lifted", True), ("held", True)) + ladder.motion_valid = False + ladder.motion_detail = "joint_limit_violation" + ladder.max_tracking_error_rad = 1.5 + + assert ladder.success + assert ladder.as_row_fields()["motion_valid"] == "0.000000" + assert ladder.as_row_fields()["max_tracking_error_rad"] == "1.5000" + + +def test_stage_rates_divide_by_the_cases_that_reached_the_stage() -> None: + """stage_success_rate[i] = passed(i) / reached(i), success = passed(last).""" + results = [ + {"ladder": _ladder(("grasped", True), ("lifted", True), ("held", True))}, + {"ladder": _ladder(("grasped", True), ("lifted", False))}, + {"ladder": _ladder(("grasped", False))}, + ] + + row = build_stage_leaderboard("pick_up", results)[0] + + assert row["grasped_rate"] == "66.67%" + assert row["lifted_rate"] == "50.00%" + assert row["held_rate"] == "100.00%" + assert row["success_rate"] == "33.33%" + assert row["evaluated_cases"] == 3 + + +def test_a_stage_no_case_reached_is_not_applicable() -> None: + """A rate with an empty denominator is reported as N/A, not as zero.""" + row = build_stage_leaderboard("pick_up", [{"ladder": _ladder(("grasped", False))}])[ + 0 + ] + + assert row["lifted_rate"] == "N/A" + assert row["success_rate"] == "0.00%" + + +def test_a_drop_is_measured_against_the_lowest_point_of_the_replay() -> None: + """An object dropped mid-motion counts even if it rolls back to the target.""" + support = 0.50 + + assert dropped_below_support(support - PHYSICAL_DROP_MARGIN_M - 0.01, support) + assert not dropped_below_support(support - PHYSICAL_DROP_MARGIN_M + 0.01, support) + assert not dropped_below_support(float("nan"), support) + + +def test_release_compares_the_two_commands_the_skill_used() -> None: + """The hand is released when it ends nearer its open than its closed pose.""" + import torch + + class _Robot: + def __init__(self, achieved: list[float]) -> None: + self._achieved = torch.tensor([achieved]) + + def get_qpos(self, name: str, target: bool) -> torch.Tensor: + del name, target + return self._achieved + + open_qpos = torch.tensor([0.0, 0.0]) + close_qpos = torch.tensor([0.04, 0.04]) + + assert hand_is_released(_Robot([0.005, 0.005]), "hand", open_qpos, close_qpos) + assert not hand_is_released(_Robot([0.038, 0.038]), "hand", open_qpos, close_qpos) From 3df653113555927b6f292b1dff14cb3623e3c5cd Mon Sep 17 00:00:00 2001 From: Xinyi YUAN Date: Tue, 22 Sep 2026 16:04:14 +0900 Subject: [PATCH 03/10] docs(bench): report the measured coverage results Replaces the earlier report, which described the four-level ladder and carried smoke-profile results for the five pre-existing benchmarks. Every skill is now a coverage run under the standard, presented as the stage chain that produces its success_rate. Eight skills pass every case. Four do not, and the report says where each loses them: - pick_up 52.08%: 15 of 48 cases report a held object while the object never leaves the table, which only the separate grasped and lifted stages expose. - place 37.50%: the tighter tolerance accounts for 7 cases; 17 more miss by over 10 cm. - move_held_object 27.08%: median position error 0.5411 m, so not a tolerance effect; at the previous 0.12 m only 15 of 40 would pass. - hand_over 0%: both cases fail only at placed, against the derived 3 cm budget. Also records the control run showing the 4 to 16 steps-per-waypoint replay change leaves pick_up's rates identical, and keeps the two criteria that do not discriminate: press at any commanded depth, and Twist's knob joint. Co-Authored-By: Claude Opus 5 --- .../atomic_action/BENCHMARK_REPORT.md | 441 ++++++------------ 1 file changed, 134 insertions(+), 307 deletions(-) diff --git a/scripts/benchmark/atomic_action/BENCHMARK_REPORT.md b/scripts/benchmark/atomic_action/BENCHMARK_REPORT.md index ad48e0fce..4a5eb72f4 100644 --- a/scripts/benchmark/atomic_action/BENCHMARK_REPORT.md +++ b/scripts/benchmark/atomic_action/BENCHMARK_REPORT.md @@ -1,354 +1,181 @@ -# Atomic-Action Benchmark: Results and Technical Report +# Atomic-Action Benchmark: Results -Branch `xinyi/atomic-bench-all` in `/tmp/bench_night`, based on `main` -(`7bc7271c`). Nothing is committed; the working tree holds all changes. +Twelve skills measured under +[`BENCHMARK_STANDARD.md`](BENCHMARK_STANDARD.md). Every number below is a +`--profile coverage --device cpu` run on `embodichain311` (Python 3.11), DexSim +default backend, 10 ms physics timestep, one environment, `--repeat 1`. Per-case +tables are in `outputs/benchmarks/`. -Environment: `embodichain311` (Python 3.11), `--device cpu`, DexSim default -backend, physics timestep 10 ms, single environment. Reports in -`outputs/benchmarks/`. Metric definitions and threshold justifications live in -[`BENCHMARK_STANDARD.md`](BENCHMARK_STANDARD.md). +`success_rate` is the last link of a chain. Each stage is scored only on the +cases that reached it, so a rate says what fraction of the cases that got there +passed, and the product of the chain is the suite's `success_rate`: ---- - -## 1. One-page summary - -**12 atomic-action benchmarks now run end to end.** Five existed but could not -run on `main`; seven are new. - -| Skill | Status | plan | valid | exec | task | -|---|---|---|---|---|---| -| `move_joints` | restored | ok | ok | ok | ok | -| `move_end_effector` | restored | ok | ok | ok | ok | -| `pick_up` | restored | ok | ok | ok | ok | -| `place` | restored | ok | ok | ok | ok | -| `move_held_object` | restored | ok | ok | ok | ok | -| `open_door` | **new** | 3/3 | 3/3 | 3/3 | **3/3** | -| `press` | **new** | 3/3 | 3/3 | 3/3 | **3/3** | -| `slide` | **new** | 3/3 | 3/3 | 3/3 | **3/3** | -| `axis_align` | **new** | 2/2 | 2/2 | 2/2 | **2/2** | -| `pour` | **new** | 3/3 | 3/3 | 3/3 | **3/3** | -| `hand_over` | **new** | 2/2 | 2/2 | 2/2 | **2/2** | -| `twist` | **new** | 3/3 | 3/3 | 3/3 | **0/3** | - -**Twist is the only skill that does not reach `task_success`, and the cause is -not Twist's planning.** Its planned end-effector rotation about the knob axis is -exactly the commanded angle; the simulated grasp cranks the freely-spinning knob -past it. Details in section 4. - -**Not completed:** `push_object`, `coordinated_pickment`, -`coordinated_placement`. Section 6. +``` +stage_success_rate[i] = passed(i) / reached(i) reached(i) = passed(i-1) +success_rate = passed(last) / total +``` -**Needs your decision:** seven threshold/scope questions, section 7. +Plan validity and replay tracking are reported per case and fail nothing. --- -## 2. What was broken, and what fixing it required - -### 2.1 Restoring the five shipped benchmarks - -PR #620's fixes were re-applied by cherry-picking `42728c12` with `-n` (staged, -not committed): missing tutorial exports, missing planning context, missing -simulator teardown, a stale `get_hand_open_close_qpos` signature, and a -too-tight `move_end_effector` tolerance. - -That alone was not enough. **Additional fix required by the current DexSim:** -the extracted `create_robot` helpers never called `sim.prepare()`, so -`robot.body_data` was `None` and every benchmark died at the first -`robot.get_qpos()`. - -- *Before*: `AttributeError: 'NoneType' object has no attribute 'qpos'` in - `articulation.py:1619`, for all five. -- *Fix*: `sim.prepare()` inside each tutorial `create_robot`, and inside - `common.create_benchmark_object` after `add_rigid_object` (adding a body - changes the spawn topology). -- *After*: all five run and write reports. `move_joints` 123 ms, success, clean - exit. - -### 2.2 The replay-rate defect (affects every contact skill) - -This was the most consequential finding of the night. - -`Robot.set_qpos` writes **drive targets**, not teleported state. The benchmark -replay advanced one waypoint per 4 physics steps with no check that the arm had -actually got there. Sweeping the rate on OpenDoor: - -| Steps/waypoint | Max arm tracking error (rad) | Hinge error (rad) | +## 1. Results + +| Skill | Cases | Stage chain | `success_rate` | +|---|---|---|---| +| `move_joints` | 3 | reached 100% | **100%** | +| `move_end_effector` | 4 | reached 100% | **100%** | +| `open_door` | 3 | grasped 100% → opened 100% → released 100% | **100%** | +| `slide` | 3 | grasped 100% → slid 100% → released 100% | **100%** | +| `press` | 3 | contacted 100% → pressed 100% | **100%** | +| `pour` | 3 | poured 100% | **100%** | +| `twist` | 3 | grasped 100% → twisted 100% → released 100% | **100%** | +| `axis_align` | 2 | grasped 100% → aligned 100% → held 100% | **100%** | +| `pick_up` | 48 | grasped 87.50% → lifted 59.52% → held 100% | **52.08%** | +| `place` | 48 | released 87.50% → placed 42.86% → stable 100% | **37.50%** | +| `move_held_object` | 48 | transported 27.08% | **27.08%** | +| `hand_over` | 2 | grasped 100% → transferred 100% → handed_over 100% → placed 0% | **0%** | + +Measured values behind the passing skills, against the tolerance each is scored +at: + +| Skill | Measured | Tolerance | |---|---|---| -| 1 (planner's own dt) | 1.3844 | 0.2273 | -| 4 (original) | 0.4390 | 0.0689 | -| 16 (**chosen**) | 0.0532 | 0.0306 | -| 32 | 0.0230 | 0.0245 | - -The measured "task" error was largely the controller failing to follow the plan. -I first tried replaying at the planner's own `dt`, which is *worse* — it made -OpenDoor and Slide fail outright. - -**Fix**: `DEFAULT_REPLAY_STEPS_PER_WAYPOINT = 16`, plus a new -`execution_success` gate on `max_tracking_error_rad <= 0.10`, so a case is never -credited or blamed for motion the robot did not perform. - -*Evidence, Slide 180 mm pull:* 0.0525 m error at 1 step → 0.0098 m at 4 → -**0.0006 m at 16**. - -### 2.3 Measurement-point defects (my benchmark code, found by disbelieving green/red) - -Three skills were initially mis-measured. In each case the skill was correct and -the benchmark was wrong. - -**`open_door`** — first run reported `task_success=False`, hinge error 0.380 rad. -Instrumenting each segment boundary showed the hinge reached 0.979 rad at the -end of the `open` segment and then **settled back to 0.667 rad** after the hand -released and the arm retracted: the tutorial microwave's hinge is undriven -(`drive_type="none"`) and swings freely. Scoring moved to the end of the `open` -segment. Settled value retained as a diagnostic only. - -**`pour`** — reported `task_success=False` with ~0.014 rad achieved for a -1.571 rad command. The planned `pour` segment showed -`max_joint_change_over_pour_segment = 0.0000 rad`. Reading -`primitives/pour.py` explained it: Pour plans **two** end-effector targets in -one segment — rotate to the poured pose, then *return to the pose it started -from*. The end of the segment is zero rotation by construction. Scoring moved to -the peak signed rotation. Error dropped to 0.002–0.006 rad. - -**`pour` (second defect, mine)** — PickUp refused to plan in the Pour scene -(`plan failure [pick_up]: planning failed`). Cause: I passed -`strategy="motion_gen"` on the PickUp invocation. Removing it (matching the -working `pickup_benchmark`) made it plan. See 2.4 for the related tutorial bug. - -**`axis_align`** — `KeyError: "axis_align_object_pose references unknown scene -entity 'cube'"`. AxisAlign resolves the object pose from the scene by entity id, -so the engine must be built with -`create_simulation_atomic_action_engine(scene_entities=(obj,))`, not a bare -`AtomicActionEngine`. Fixed; both cases now pass from a 90-degree misalignment. - -### 2.4 A pre-existing bug found in the Pour tutorial - -Running `scripts/tutorials/atomic_action/pour.py` **on this branch, unmodified**, -crashes: +| `move_joints` | 0.002996 rad, all three cases | 0.00524 rad | +| `move_end_effector` | 0.0006 – 0.0015 m | 0.003 m | +| `open_door` | hinge error 0.0232 – 0.0289 rad | 0.08727 rad | +| `slide` | travel error 0.0002 – 0.0010 m | 0.01 m | +| `press` | peak 6.00 mm of a 6 mm stroke, all three cases | 80 % of stroke | +| `pour` | peak-rotation error 0.0035 – 0.0054 rad | 0.08727 rad | +| `twist` | end-effector rotation error 0.0024 – 0.0085 rad | 0.08727 rad | +| `axis_align` | residual axis angle 0.0480, 0.0485 rad | 0.08727 rad | + +`move_joints` and `move_end_effector` are read off the robot after a physical +replay and a terminal settle. Read from the planner's last waypoint, as the +suite did before, they report 0.000000 rad and 0.00002 m, which describes the +solver. -``` -ValueError: Quantity sampling requires at least one sample per retained -waypoint; received 108, requires at least 277. -``` +--- -`PICK_MOTION_SAMPLE_COUNT = PICK_SAMPLE_INTERVAL - HAND_INTERP_STEPS` = 108, but -the retained waypoint count is 277. The tutorial's `_create_pick_motion_policy` -forces `strategy="motion_gen"` with an under-sized `sample_interval`. +## 2. Where the four failing skills lose their cases -I did **not** change the tutorial — the benchmark sidesteps it by leaving the -strategy at its default, and the tutorial fix is a separate decision (raise the -sample count, or stop forcing `motion_gen`). Flagging it because it means Pour's -tutorial is currently unusable. +### `pick_up` — 52.08 %, and the grasp state disagrees with the scene ---- +| Outcome | Cases | +|---|---| +| Passed every stage | 25 | +| Failed `lifted` | 17 | +| Failed `grasped`, `planner_reported_failure` | 6 | -## 3. Results - -All numbers from the final verification run after `black` formatting. Planning -time excludes a discarded warm-up compile. - -### 3.1 Success ladder and task metrics - -| Skill | Case | plan | valid | exec | task | task metric (measured vs rule) | -|---|---|---|---|---|---|---| -| `open_door` | open_30 | ok | ok | ok | **ok** | hinge err 0.0318 rad ≤ 0.15 | -| | open_60 | ok | ok | ok | **ok** | hinge err 0.0332 rad ≤ 0.15 | -| | open_90 | ok | ok | ok | **ok** | hinge err 0.0415 rad ≤ 0.15 | -| `press` | press_10mm | ok | ok | ok | **ok** | peak 6.00 mm ≥ 4.80 mm (80 % of stroke) | -| | press_20mm | ok | ok | ok | **ok** | peak 6.00 mm ≥ 4.80 mm | -| | press_30mm | ok | ok | ok | **ok** | peak 6.00 mm ≥ 4.80 mm | -| `slide` | pull_100mm | ok | ok | ok | **ok** | travel 0.0998 m, err 0.0002 m ≤ 0.03 | -| | pull_180mm | ok | ok | ok | **ok** | travel 0.1794 m, err 0.0006 m ≤ 0.03 | -| | pull_250mm | ok | ok | ok | **ok** | travel 0.2444 m, err 0.0056 m ≤ 0.03 | -| `axis_align` | upright | ok | ok | ok | **ok** | 1.5709 → 0.0480 rad ≤ 0.15 | -| | horizontal | ok | ok | ok | **ok** | 1.5708 → 0.0412 rad ≤ 0.15 | -| `pour` | pour_30 | ok | ok | ok | **ok** | peak 0.5217 rad, err 0.0019 ≤ 0.15 | -| | pour_45 | ok | ok | ok | **ok** | peak 0.7796 rad, err 0.0058 ≤ 0.15 | -| | pour_90 | ok | ok | ok | **ok** | peak 1.5649 rad, err 0.0059 ≤ 0.15 | -| `hand_over` | vertical_can | ok | ok | ok | **ok** | delivery 0.1123 m ≤ 0.12; min z 0.531 m, not dropped | -| | horizontal_pencil | ok | ok | ok | **ok** | delivery 0.0678 m ≤ 0.12; min z 0.532 m, not dropped | -| `twist` | twist_ccw_30 | ok | ok | ok | **FAIL** | knob 2.0286 rad vs 0.5236 commanded, err 1.5050 | -| | twist_ccw_45 | ok | ok | ok | **FAIL** | knob 2.4024 rad vs 0.7854, err 1.6170 | -| | twist_ccw_90 | ok | ok | ok | **FAIL** | knob 3.1416 rad vs 1.5708, err 1.5708 | - -Restored benchmarks (smoke profile): `move_joints` success, joint err 0.000000; -`move_end_effector` success, err 0.0000; `pick_up` success, object lift -0.158 m, XY drift 0.004 m; `place` success; `move_held_object` success. - -### 3.2 Cost - -| Skill | Planning time (ms) | Waypoints | CPU delta (MB) | Peak GPU (MB) | -|---|---|---|---|---| -| `open_door` | 270–287 | 120 | 0.0–7.6 | 29.1 | -| `press` | 316–321 | 140 | 0.0 | 0.0 | -| `slide` | 315–325 | 140 | 0.0–7.7 | 45.7 | -| `twist` | 229–237 | 140 | 0.0 | 0.0 | -| `axis_align` | 321–332 | 180 | 0.0–8.4 | 24.5 | -| `pour` (pick+pour) | 278–284 | 200 | 0.8–6.8 | 42.8 | -| `hand_over` | 665–685 | 220 | — | — | -| `move_joints` | 123 | — | — | — | -| `move_end_effector` | 69 | — | — | — | -| `pick_up` | 579 | 120 | — | — | - -Run on CPU, so GPU deltas are Torch-side only. `press` and `twist` allocate no -GPU memory because they sample no grasps. The CPU delta of the first case in -each sweep is larger than subsequent ones (allocator growth) even after warm-up; -this is visible in the per-case tables. +Of the 17 that failed `lifted`, **15 never moved the object at all** — the +measured lift is under 5 mm. The skill's own held-object state reports a grasp +in every one of them, which is what `grasped` scores; the object stays on the +table. That gap between the held-object state and the scene is the finding, and +it is only visible because the two are separate stages. ---- +`held` is 100 %: nothing that came up ever fell. -## 4. Twist: root-cause analysis +### `place` — 37.50 %, and the tighter tolerance is not the main cause -`twist` is the one skill whose `task_success` fails. The evidence says the -failure is in the scene's contact physics, not in Twist. +| Outcome | Cases | +|---|---| +| Passed every stage | 18 | +| Failed `placed` | 24 | +| Failed `released`, PickUp precondition raised | 6 | -**What was ruled out:** +XY error over the 42 scored cases has a median of 0.0163 m. At the standard's +1 cm, 18 pass; at the 10 cm the suite used before, 25 would. **The tolerance +change accounts for 7 cases; the other 17 miss by more than 10 cm.** `stable` +is 100 %: whatever landed on the target stayed there. -1. *Replay rate.* Arm tracking error at the benchmark rate is **0.026 rad** — - the arm follows the plan almost exactly. Over-rotation persists at every rate - from 2 to 32 steps per waypoint. -2. *Grasp not closing.* The hand is commanded to 0.036 and stalls at 0.0136, - i.e. it is physically blocked by the 5 cm knob. Contact is real. -3. *Planning.* Measured directly: the resolved twist axis is - `(-1, 0, 0)` in world, and the **planned end-effector rotation about that - axis over the `twist` segment is 0.7854 rad for a 0.7854 rad command, with - the correct sign.** Twist plans exactly what it was asked to. +### `move_held_object` — 27.08 %, and the misses are half a metre -**What the instrumentation shows instead:** per-segment knob angle for a -0.7854 rad command — +| Position and rotation error, 40 scored cases | Cases | +|---|---| +| Within 1 cm and 5° | 13 | +| Rotation only outside | 1 | +| Both outside | 26 | -``` -end_of_close knob=+0.0108 -end_of_twist knob=+1.8736 <- 2.4x commanded, opposite sign -end_of_open knob=+1.8524 (hand has opened) -end_of_retract knob=+2.3749 <- still being dragged after release -after_hold knob=+2.3749 drift=0.0000 -``` +Median position error is **0.5411 m**, maximum 1.6343 m. At the 12 cm the suite +used before, only 15 of the 40 would pass, so this is not a tolerance effect: +the skill puts the object somewhere else entirely in most coverage poses. The +remaining 8 of the 48 produced no measurement because the case failed earlier. -The knob keeps being driven a further **0.52 rad during `retract`, after the -hand opened**, and then stops dead (zero drift). It is not spinning on -momentum — it is being cranked by contact with the withdrawing fingers. +This skill also gained an orientation criterion. Its goal commands a pose, not +a point, and only the position was scored before; exactly one case fails on +rotation alone, so the criterion is not what moved the number. -**Mechanism:** the gripper wedges against the knob rather than holding it -rigidly, and the knob joint has `stiffness=1e-3`, `damping=1e2`, -`max_effort=1e-2` — essentially no restoring torque — so any tangential contact -force turns it freely past the commanded angle. +### `hand_over` — 0 %, failing only at the last stage -**I did not "fix" this by loosening the tolerance or by stiffening the knob's -joint drive.** Either would manufacture a pass. The benchmark reports -`task_goal_miss` with the measured value, and section 7 asks how you want Twist -gated. +Both cases pass `grasped`, `transferred` and `handed_over`, then miss `placed`: +delivery distance 0.2372 m and 0.0764 m against the derived budget of +3 × `TASK_POSITION_TOLERANCE_M` = 0.03 m. Neither case dropped the object. + +An earlier run of the same two cases measured 0.1123 m and 0.0678 m. Grasp +sampling is stochastic and every number here is `--repeat 1`, so these are point +estimates, not confidence intervals. --- -## 5. Changes made +## 3. The replay rate does not decide these results -Working tree on `xinyi/atomic-bench-all`, uncommitted. +The physical-validation replay moved from 4 to the 16 physics steps per +waypoint the standard calibrates at. Running `pick_up` coverage at both rates: -**New benchmarks** (`scripts/benchmark/atomic_action/`): `open_door_benchmark.py`, -`press_benchmark.py`, `slide_benchmark.py`, `twist_benchmark.py`, -`axis_align_benchmark.py`, `pour_benchmark.py`, `hand_over_benchmark.py`. All -registered in -`run_benchmark.ACTION_MODULES`. +| Steps per waypoint | grasped | lifted | held | `success_rate` | +|---|---|---|---|---| +| 4 (previous) | 87.50% | 59.52% | 100.00% | 52.08% | +| 16 (standard) | 87.50% | 59.52% | 100.00% | 52.08% | -**`common.py`** — new shared infrastructure: -`SuccessLadder`, `FAILURE_TAXONOMY`, `LADDER_STAGES`, `check_motion_valid`, -`JointDisplacementTrace`, `replay_and_track_scalar` (the single replay -primitive), `replay_and_track_joint`, `run_articulated_contact_case`, -`waypoint_step_counts`, `warmup_planning`, `build_ladder_leaderboard`, -`DEFAULT_REPLAY_STEPS_PER_WAYPOINT`, `REPLAY_TRACKING_TOLERANCE_RAD`. -Plus the `sim.prepare()` fix in `create_benchmark_object`. +Identical in aggregate. Six of the 48 cases swap verdicts, three in each +direction — margin noise. The rate changes measurement fidelity, not the thing +being measured. -**Tutorials** — cherry-picked #620 exports, plus `sim.prepare()` in each -extracted `create_robot`. No tutorial behaviour was otherwise changed; in -particular the Pour tutorial's bug (2.4) is reported, not patched. +--- -**Formatting** — `black==26.3.1` clean across -`scripts/benchmark/atomic_action/` and `scripts/tutorials/atomic_action/`. +## 4. Two criteria that do not discriminate -All 12 modules import and expose `run_all_benchmarks` / `add_benchmark_args`. +- **`press`** passes at every commanded depth. Commanding 10, 20 or 30 mm all + drive the button to 6.00 mm of its 6 mm stroke, so the 80 % rule shows that + the button was pressed and nothing about how far it was asked to go. +- **`twist`'s knob joint** is a diagnostic, not the criterion. It reaches + 2.0261, 2.5326 and 3.1416 rad for 0.5236, 0.7854 and 1.5708 rad commanded: + the gripper wedges against the 5 cm knob and the joint offers no resistance, + and the 90° case is simply at the asset's limit. The executed end-effector + rotation about the knob axis, which the standard scores, is within 0.0085 rad + in all three cases. The axis is probed off the asset rather than assumed. --- -## 6. Not completed +## 5. Not covered -| Skill | Status | Reason | -|---|---|---| -| `push_object` | not built | No tutorial. The only reference usage is `tests/sim/atomic_actions/test_actions.py`, which drives it with a **mocked** motion generator and synthetic `SceneSnapshot` poses — no physical scene exists to reuse, so a benchmark needs a scene designed from scratch (object, support surface, contact offsets). Deferred after the priority-2 skills. | -| `coordinated_pickment` | not built | Dual-arm, `left`/`right` binding. Not reached. | -| `coordinated_placement` | not built | Dual-arm, and the most involved: requires two sequential PickUps, then re-deriving held state from actual measurements (`compute_actual_held_state`) before planning placement. Not reached. | - -The API facts needed for all four were gathered and are summarized in this -session; none is blocked by a missing dependency. **No skill was blocked by -cuRobo** — every benchmark here uses the TOPPRA/trapezoidal path, so the -"needs cuRobo, cannot verify locally" case did not arise. - ---- +`push_object`, `coordinated_pickment` and `coordinated_placement` have stages +defined in the standard but no benchmark module. -## 7. Decisions needed from you - -1. **How should `twist` be gated?** Options: (a) keep the knob-joint rule and - accept Twist failing on this asset; (b) gate on the executed end-effector - rotation about the twist axis, which is currently exact; (c) give the - benchmark scene a stiffer knob drive than the tutorial's. *Suggested: (b), - keeping the knob joint as a reported diagnostic. (c) is tuning a scene to make - a test pass and I'd advise against it without an independent reason.* - **Status: undecided.** - -2. **Is `press`'s gate meaningful?** Commanding 3 mm still drove the button to - 5.66 mm of its 6 mm stroke, so the 80 %-of-stroke rule passes regardless of - commanded travel. *Suggested: keep it as an explicit liveness check ("the - button was pressed") and stop treating commanded depth as a controlled - variable on this asset.* **Status: undecided.** - -3. **Tighten `slide`'s tolerance?** 0.03 m is ~30x the measured error (max - 0.0056 m). *Suggested: 0.01 m, which still leaves ~2x margin on the worst - measured case.* **Status: undecided.** - -4. **Should the five original benchmarks adopt the calibrated replay?** They - still use 4 steps/waypoint via - `replay_trajectory_for_physical_validation`, with no tracking check. They - pass, but carry the same systematic bias section 2.2 quantifies. I left their - behaviour unchanged deliberately. *Suggested: align them, in a separate - change.* **Status: undecided.** - -5. **`open_door` at larger angles.** Error grows with commanded angle - (0.0318 / 0.0332 / 0.0415 rad at 30 / 60 / 90 degrees). A 120-degree case was - not tested and could approach the 0.15 rad tolerance. Worth adding before - trusting the tolerance at the extremes. - -6. **Repeat count.** Everything is `--repeat 1`; these are point estimates. The - design document asks for confidence intervals, which needs repeats and a - seeding policy. - -7. **`hand_over`'s delivery tolerance.** 0.12 m was borrowed from the existing - MoveHeldObject tolerance. The vertical-can case lands at 0.1123 m, i.e. only - 7 % inside the gate, so it would flip to a failure under small perturbation. - Either the tolerance is too tight for HandOver, or the delivery is genuinely - marginal and worth investigating. *Suggested: investigate before loosening.* - **Status: undecided.** - -Also awaiting your call: whether to fix the Pour tutorial bug (2.4) here or in a -separate change. +`robustness` and `adaptability` are not measured here. This report is section 1 +of the standard only. --- -## 8. Reproducing +## 6. Reproducing ``` -cd /tmp/bench_night -export PYTHONPATH=/tmp/bench_night PY=/home/lqin/miniconda3/envs/embodichain311/bin/python -$PY -m scripts.benchmark.atomic_action.open_door_benchmark --profile coverage --device cpu --n_sample 2000 -$PY -m scripts.benchmark.atomic_action.slide_benchmark --profile coverage --device cpu --n_sample 2000 -$PY -m scripts.benchmark.atomic_action.axis_align_benchmark --profile coverage --device cpu --n_sample 2000 -$PY -m scripts.benchmark.atomic_action.pour_benchmark --profile coverage --device cpu --n_sample 2000 -$PY -m scripts.benchmark.atomic_action.press_benchmark --profile coverage --device cpu -$PY -m scripts.benchmark.atomic_action.twist_benchmark --profile coverage --device cpu -$PY -m scripts.benchmark.atomic_action.hand_over_benchmark --profile coverage --device cpu --n_sample 2000 +$PY -m scripts.benchmark.atomic_action.move_joints_benchmark --profile coverage --device cpu +$PY -m scripts.benchmark.atomic_action.move_end_effector_benchmark --profile coverage --device cpu +$PY -m scripts.benchmark.atomic_action.pickup_benchmark --profile coverage --device cpu --n_sample 2000 +$PY -m scripts.benchmark.atomic_action.place_benchmark --profile coverage --device cpu --n_sample 2000 +$PY -m scripts.benchmark.atomic_action.move_held_object_benchmark --profile coverage --device cpu --n_sample 2000 +$PY -m scripts.benchmark.atomic_action.open_door_benchmark --profile coverage --device cpu --n_sample 2000 +$PY -m scripts.benchmark.atomic_action.slide_benchmark --profile coverage --device cpu --n_sample 2000 +$PY -m scripts.benchmark.atomic_action.axis_align_benchmark --profile coverage --device cpu --n_sample 2000 +$PY -m scripts.benchmark.atomic_action.pour_benchmark --profile coverage --device cpu --n_sample 2000 +$PY -m scripts.benchmark.atomic_action.press_benchmark --profile coverage --device cpu +$PY -m scripts.benchmark.atomic_action.twist_benchmark --profile coverage --device cpu +$PY -m scripts.benchmark.atomic_action.hand_over_benchmark --profile coverage --device cpu --n_sample 2000 ``` `press` and `twist` sample no grasps and do not accept `--n_sample`. A DexSim -exit code of 139 on teardown is the known-harmless segfault; judge each run by -its printed output and the written report. +exit code of 139 on teardown is the known-harmless segfault; judge a run by its +printed output and the written report. From 2b3304af8891333d658be660fa795fe7ec26e63c Mon Sep 17 00:00:00 2001 From: Xinyi YUAN Date: Tue, 22 Sep 2026 16:11:32 +0900 Subject: [PATCH 04/10] fix(bench): build in-process child arguments from the selected module --in_process exposes all twelve actions, but _make_child_args() built the child namespace from a hand-kept list of the five original benchmarks' arguments. The seven added here read their own case selection -- door_cases, press_cases, slide_cases, twist_cases, align_cases, pour_cases, hand_over_cases -- and every one raised AttributeError on its first line of work, the smoke profile included: python -m scripts.benchmark.atomic_action.run_benchmark \ --action press --profile smoke --device cpu --in_process AttributeError: 'Namespace' object has no attribute 'press_cases' The namespace now starts from the selected module's own parser defaults, so a benchmark's arguments are always the ones it declares, and the aggregate run's shared settings are applied over them. Object, position and approach selections are forwarded only when the aggregate run was given them, and a shared setting is applied only where the module declares it, so a skill that samples no grasps is not handed n_sample. Tests parametrize over every registered action, compare the child namespace against the attributes each benchmark module actually reads, and check that each action keeps its own case-selection defaults. Against the namespace this commit replaces, those tests fail for all seven added actions. Co-Authored-By: Claude Opus 5 --- .../benchmark/atomic_action/run_benchmark.py | 91 +++++++++----- .../atomic_action/test_aggregate_dispatch.py | 119 ++++++++++++++++++ 2 files changed, 180 insertions(+), 30 deletions(-) create mode 100644 tests/benchmark/atomic_action/test_aggregate_dispatch.py diff --git a/scripts/benchmark/atomic_action/run_benchmark.py b/scripts/benchmark/atomic_action/run_benchmark.py index 482c798b1..a99c3c62b 100644 --- a/scripts/benchmark/atomic_action/run_benchmark.py +++ b/scripts/benchmark/atomic_action/run_benchmark.py @@ -23,6 +23,7 @@ from __future__ import annotations import argparse +import importlib import shlex import subprocess import sys @@ -169,34 +170,65 @@ def _validate_object_types_for_actions( ) -def _make_child_args(args: argparse.Namespace) -> argparse.Namespace: - """Build minimal child benchmark arguments for aggregate dispatch.""" +def _make_child_args( + args: argparse.Namespace, + action_name: str, +) -> argparse.Namespace: + """Build one benchmark's arguments for in-process aggregate dispatch. + + The namespace starts from the selected module's own parser defaults, so a + benchmark's case-selection arguments are always present no matter which + ones it declares. Building it from a fixed list here instead would leave + every newly registered action without its own arguments, which the + benchmark then reads and fails on. + + Args: + args: Aggregate benchmark arguments. + action_name: Atomic action whose module supplies the defaults. + + Returns: + Child arguments carrying the module's defaults with the aggregate + run's shared settings applied over them. + """ + module = importlib.import_module(ACTION_MODULES[action_name]) + parser = argparse.ArgumentParser(add_help=False) + module.add_benchmark_args(parser) + child_args = parser.parse_args([]) + profile = resolve_profile(args) - return argparse.Namespace( - smoke=profile == "smoke", - profile=profile, - repeat=1, - device=args.device, - renderer=args.renderer, - object_types=args.object_types, - position_cases=args.position_cases, - pose_cases=["all"], - sequence_cases=["all"], - approach_cases=args.approach_cases, - place_cases=None, - held_object_cases=None, - n_sample=1000 if profile == "smoke" else 10000, - force_reannotate=False, - record_video=args.record_video, - record_failed_video=args.record_failed_video, - video_case_limit=args.video_case_limit, - video_dir=args.video_dir, - video_fps=args.video_fps, - video_max_memory=args.video_max_memory, - video_width=args.video_width, - video_height=args.video_height, - video_hold_steps=args.video_hold_steps, - ) + shared: dict[str, object] = { + "smoke": profile == "smoke", + "profile": profile, + "repeat": 1, + "device": args.device, + "renderer": args.renderer, + "record_video": args.record_video, + "record_failed_video": args.record_failed_video, + "video_case_limit": args.video_case_limit, + "video_dir": args.video_dir, + "video_fps": args.video_fps, + "video_max_memory": args.video_max_memory, + "video_width": args.video_width, + "video_height": args.video_height, + "video_hold_steps": args.video_hold_steps, + # The aggregate run samples fewer grasps than a single benchmark does. + "n_sample": 1000 if profile == "smoke" else 10000, + "force_reannotate": False, + } + # Object and position selections belong to the mesh-object benchmarks, and + # are only forwarded when the aggregate run was given them. + for name, value in ( + ("object_types", args.object_types), + ("position_cases", args.position_cases), + ("approach_cases", args.approach_cases), + ): + if value: + shared[name] = value + + for name, value in shared.items(): + if hasattr(child_args, name): + setattr(child_args, name, value) + return child_args def _make_child_cli_args(args: argparse.Namespace, action_name: str) -> list[str]: @@ -301,9 +333,8 @@ def _run_in_process_benchmarks( """Run selected benchmarks in the current Python process.""" reports: list[Path] = [] for action_name in selected_actions: - module_name = ACTION_MODULES[action_name] - module = __import__(module_name, fromlist=["run_all_benchmarks"]) - child_args = _make_child_args(args) + module = importlib.import_module(ACTION_MODULES[action_name]) + child_args = _make_child_args(args, action_name) report_path = module.run_all_benchmarks(child_args) reports.append(report_path) return reports diff --git a/tests/benchmark/atomic_action/test_aggregate_dispatch.py b/tests/benchmark/atomic_action/test_aggregate_dispatch.py new file mode 100644 index 000000000..ba32ba615 --- /dev/null +++ b/tests/benchmark/atomic_action/test_aggregate_dispatch.py @@ -0,0 +1,119 @@ +# ---------------------------------------------------------------------------- +# Copyright (c) 2021-2026 DexForce Technology Co., Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ---------------------------------------------------------------------------- + +"""Tests for the aggregate dispatcher's in-process path. + +``--in_process`` runs a benchmark inside the aggregate process, so the child +namespace has to carry the arguments that benchmark reads. Building it from a +hand-kept list left every newly registered action without its own +case-selection arguments, and each one raised ``AttributeError`` on its first +line of work. +""" + +from __future__ import annotations + +import argparse +import importlib +import inspect +import re +from pathlib import Path + +import pytest + +from scripts.benchmark.atomic_action.run_benchmark import ( + ACTION_MODULES, + _make_child_args, + add_benchmark_args, +) + +ARGS_ATTRIBUTE = re.compile(r"\bargs\.([a-z_]+)") + + +def _aggregate_args(argv: list[str] | None = None) -> argparse.Namespace: + """Return aggregate benchmark arguments as the CLI would parse them.""" + parser = argparse.ArgumentParser(add_help=False) + add_benchmark_args(parser) + return parser.parse_args(argv or []) + + +@pytest.mark.parametrize("action_name", sorted(ACTION_MODULES)) +def test_every_action_receives_the_arguments_its_benchmark_reads( + action_name: str, +) -> None: + """Each dispatched benchmark finds every ``args`` attribute it reads.""" + module = importlib.import_module(ACTION_MODULES[action_name]) + expected = set(ARGS_ATTRIBUTE.findall(inspect.getsource(module))) + + child_args = _make_child_args(_aggregate_args(), action_name) + + missing = sorted(name for name in expected if not hasattr(child_args, name)) + assert not missing, f"{action_name} would raise AttributeError for {missing}" + + +@pytest.mark.parametrize("action_name", sorted(ACTION_MODULES)) +def test_each_action_keeps_its_own_case_selection_defaults(action_name: str) -> None: + """A dispatched benchmark runs the cases its own parser would select.""" + module = importlib.import_module(ACTION_MODULES[action_name]) + parser = argparse.ArgumentParser(add_help=False) + module.add_benchmark_args(parser) + standalone = parser.parse_args([]) + + child_args = _make_child_args(_aggregate_args(), action_name) + + case_arguments = [name for name in vars(standalone) if name.endswith("_cases")] + assert case_arguments, f"{action_name} declares no case selection." + for name in case_arguments: + assert getattr(child_args, name) == getattr(standalone, name) + + +def test_shared_settings_override_the_module_defaults() -> None: + """Device, profile, repeat and video settings come from the aggregate run.""" + args = _aggregate_args(["--profile", "smoke", "--device", "cpu"]) + + child_args = _make_child_args(args, "open_door") + + assert child_args.device == "cpu" + assert child_args.profile == "smoke" + assert child_args.smoke is True + assert child_args.repeat == 1 + assert child_args.video_dir == args.video_dir + assert child_args.n_sample == 1000 + + +def test_object_selection_is_forwarded_only_when_the_run_gives_one() -> None: + """An unset object selection leaves the benchmark's own default in place.""" + module = importlib.import_module(ACTION_MODULES["pick_up"]) + parser = argparse.ArgumentParser(add_help=False) + module.add_benchmark_args(parser) + standalone = parser.parse_args([]) + + default_args = _make_child_args(_aggregate_args(), "pick_up") + selected_args = _make_child_args( + _aggregate_args(["--object_types", "cube"]), "pick_up" + ) + + assert default_args.object_types == standalone.object_types + assert selected_args.object_types == ["cube"] + + +def test_a_skill_without_grasp_sampling_is_not_given_grasp_arguments() -> None: + """Shared settings are applied only where the benchmark declares them.""" + args = _aggregate_args() + child_args = _make_child_args(args, "move_joints") + + assert not hasattr(child_args, "n_sample") + assert not hasattr(child_args, "force_reannotate") + assert child_args.device == args.device From 540da09d7ce130d9d6e9804178a9a1430d25fa8e Mon Sep 17 00:00:00 2001 From: Xinyi YUAN Date: Tue, 22 Sep 2026 16:20:37 +0900 Subject: [PATCH 05/10] fix(bench): release the simulation between in-process benchmarks With the child arguments fixed, --action all --in_process reaches hand_over and fails there: ValueError: Robot DualUr5HandOver not found base_planner.py:220, SimulationManager.get_instance(...).get_robot(...) The simulator is a singleton keyed by instance id and a planner resolves its robot through that instance. Each benchmark builds its own scene, so leaving the previous one standing makes the next benchmark a second instance whose planner then looks for its robot among the first benchmark's robots. The subprocess path never sees this because every action gets a fresh process. HandOver already tore its scene down between its own cases. That helper moves to common as release_simulation(), and the dispatcher calls it after each in-process benchmark, so a benchmark always starts from a released singleton. Verified with press followed by hand_over in one process: both run and both write their report. Co-Authored-By: Claude Opus 5 --- scripts/benchmark/atomic_action/common.py | 29 +++++++++++++ .../atomic_action/hand_over_benchmark.py | 19 +-------- .../benchmark/atomic_action/run_benchmark.py | 5 +++ .../atomic_action/test_aggregate_dispatch.py | 41 +++++++++++++++++++ 4 files changed, 77 insertions(+), 17 deletions(-) diff --git a/scripts/benchmark/atomic_action/common.py b/scripts/benchmark/atomic_action/common.py index 7f53cf706..ca5fce62d 100644 --- a/scripts/benchmark/atomic_action/common.py +++ b/scripts/benchmark/atomic_action/common.py @@ -1308,6 +1308,34 @@ def hand_is_released(robot, hand_control_part: str, open_qpos, close_qpos) -> bo return to_open < to_close +def release_simulation(sim=None) -> None: + """Tear down a simulation so the next one can build its own. + + The simulator is a singleton keyed by instance id, and a planner resolves + its robot through that instance. A benchmark that runs after another in the + same process therefore has to start from a released singleton, or it builds + its scene as a second instance and then fails to find its own robot. + + Args: + sim: Simulation manager to release, or None to release the current + instance if there is one. + """ + from embodichain.lab.sim import SimulationManager + + if not SimulationManager.is_instantiated(): + return + if sim is None: + sim = SimulationManager.get_instance() + if not getattr(sim, "_is_constructed", False): + SimulationManager.reset(getattr(sim, "instance_id", 0)) + return + if sim.is_window_recording(): + sim.stop_window_record() + sim.wait_window_record_saves() + sim.destroy(exit_process=False) + SimulationManager.flush_cleanup_queue() + + def check_motion_valid(traj, robot) -> tuple[bool, str]: """Check a planned trajectory as a diagnostic, not as a success gate. @@ -1975,6 +2003,7 @@ def build_single_action_leaderboard( "park_rigid_object", "pickup_approach_direction_tuple", "record_static_scene_video", + "release_simulation", "replay_and_track_channels", "replay_and_track_joint", "replay_and_track_scalar", diff --git a/scripts/benchmark/atomic_action/hand_over_benchmark.py b/scripts/benchmark/atomic_action/hand_over_benchmark.py index 9f225b1d8..f09f0c3c6 100644 --- a/scripts/benchmark/atomic_action/hand_over_benchmark.py +++ b/scripts/benchmark/atomic_action/hand_over_benchmark.py @@ -47,6 +47,7 @@ format_float, hand_is_released, object_position_tuple, + release_simulation, replay_and_track_channels, replay_trajectory_with_recording, reset_rigid_object, @@ -339,22 +340,6 @@ def read_hand_balance(waypoint_index: int) -> float: ) -def _release_simulation(sim) -> None: - """Tear down one case's simulation so the next case can build its own.""" - from embodichain.lab.sim import SimulationManager - - if not SimulationManager.is_instantiated(): - return - if not getattr(sim, "_is_constructed", False): - SimulationManager.reset(getattr(sim, "instance_id", 0)) - return - if sim.is_window_recording(): - sim.stop_window_record() - sim.wait_window_record_saves() - sim.destroy(exit_process=False) - SimulationManager.flush_cleanup_queue() - - def _case_result( case: HandOverCase, repeat: int, @@ -585,7 +570,7 @@ def run_all_benchmarks(args: argparse.Namespace | None = None) -> Path: finally: # SimulationManager is a singleton, so a multi-case sweep must tear # the current scene down before the next case builds its own. - _release_simulation(sim) + release_simulation(sim) perf_rows, metric_rows = _build_rows(report_results) leaderboard_rows = build_stage_leaderboard("hand_over", report_results) diff --git a/scripts/benchmark/atomic_action/run_benchmark.py b/scripts/benchmark/atomic_action/run_benchmark.py index a99c3c62b..a891d24f0 100644 --- a/scripts/benchmark/atomic_action/run_benchmark.py +++ b/scripts/benchmark/atomic_action/run_benchmark.py @@ -35,6 +35,7 @@ POSITION_CASES, add_profile_benchmark_args, add_video_benchmark_args, + release_simulation, resolve_profile, ) @@ -337,6 +338,10 @@ def _run_in_process_benchmarks( child_args = _make_child_args(args, action_name) report_path = module.run_all_benchmarks(child_args) reports.append(report_path) + # Each benchmark builds its own scene. Leaving the previous one + # standing makes the next benchmark a second simulator instance, whose + # planner then resolves against the robots of the first. + release_simulation() return reports diff --git a/tests/benchmark/atomic_action/test_aggregate_dispatch.py b/tests/benchmark/atomic_action/test_aggregate_dispatch.py index ba32ba615..cb60919f0 100644 --- a/tests/benchmark/atomic_action/test_aggregate_dispatch.py +++ b/tests/benchmark/atomic_action/test_aggregate_dispatch.py @@ -29,13 +29,16 @@ import importlib import inspect import re +import types from pathlib import Path import pytest +from scripts.benchmark.atomic_action import run_benchmark from scripts.benchmark.atomic_action.run_benchmark import ( ACTION_MODULES, _make_child_args, + _run_in_process_benchmarks, add_benchmark_args, ) @@ -117,3 +120,41 @@ def test_a_skill_without_grasp_sampling_is_not_given_grasp_arguments() -> None: assert not hasattr(child_args, "n_sample") assert not hasattr(child_args, "force_reannotate") assert child_args.device == args.device + + +def test_each_in_process_benchmark_starts_from_a_released_simulation( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The simulator singleton is torn down between benchmarks. + + Leaving the previous scene standing makes the next benchmark a second + simulator instance, and its planner then resolves against the first + benchmark's robots instead of its own. + """ + calls: list[str] = [] + + def fake_import(module_name: str) -> types.SimpleNamespace: + def run_all_benchmarks(args: argparse.Namespace) -> Path: + del args + calls.append(f"run:{module_name}") + return Path(f"{module_name}.md") + + return types.SimpleNamespace( + add_benchmark_args=lambda parser: None, + run_all_benchmarks=run_all_benchmarks, + ) + + monkeypatch.setattr(run_benchmark.importlib, "import_module", fake_import) + monkeypatch.setattr( + run_benchmark, "release_simulation", lambda: calls.append("release") + ) + + reports = _run_in_process_benchmarks(_aggregate_args(), ["press", "hand_over"]) + + assert calls == [ + "run:" + ACTION_MODULES["press"], + "release", + "run:" + ACTION_MODULES["hand_over"], + "release", + ] + assert len(reports) == 2 From b178bc51ea1decbcf22a6fc33bdc63ab46107707 Mon Sep 17 00:00:00 2001 From: Xinyi YUAN Date: Tue, 22 Sep 2026 16:50:20 +0900 Subject: [PATCH 06/10] docs(bench): show one recorded failure per failing skill The report says where each of the four skills loses its cases; these are the cases themselves, captured with the suite's own --record_video --record_failed_video --video_case_limit 1 and converted to gif at the size docs/source/_static already uses. - pick_up sugar_box:q1_near:side: the hand closes, the arm follows its plan, the object never leaves the table (lift -0.000147 m). - place sugar_box:q1_near:top:left_bin: the hand opens 0.4286 m from the commanded pose. - move_held_object sugar_box:q1_near:top:front_left: 0.8288 m and 1.5911 rad off, the worst of the four. - hand_over vertical_can: a clean transfer that misses only the delivery, at 0.1981 m. A clip's numbers come from the run that produced it rather than from the coverage run, which the report states: grasp sampling is stochastic and every run is --repeat 1. Co-Authored-By: Claude Opus 5 --- .../atomic_action/hand_over_delivery_miss.gif | Bin 0 -> 1542611 bytes .../move_held_object_target_miss.gif | Bin 0 -> 2834925 bytes .../atomic_action/pick_up_lift_miss.gif | Bin 0 -> 1424929 bytes .../atomic_action/place_target_miss.gif | Bin 0 -> 2086441 bytes .../atomic_action/BENCHMARK_REPORT.md | 26 ++++++++++++++++++ 5 files changed, 26 insertions(+) create mode 100644 docs/source/_static/benchmarks/atomic_action/hand_over_delivery_miss.gif create mode 100644 docs/source/_static/benchmarks/atomic_action/move_held_object_target_miss.gif create mode 100644 docs/source/_static/benchmarks/atomic_action/pick_up_lift_miss.gif create mode 100644 docs/source/_static/benchmarks/atomic_action/place_target_miss.gif diff --git a/docs/source/_static/benchmarks/atomic_action/hand_over_delivery_miss.gif b/docs/source/_static/benchmarks/atomic_action/hand_over_delivery_miss.gif new file mode 100644 index 0000000000000000000000000000000000000000..4249a01a5509db489a795ad57f12b757ba57dfed GIT binary patch literal 1542611 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≫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
>56>_2e18Egmtas4A%OEzy%fxU}AF(?w)6n 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