Writing a new task
A task for this pipeline is two pieces:
- A YAML config (
long_tamp.config.yaml_loaderformat) describing the scene: objects, their grasp/dock handles, the grippers involved, and which joints to freeze during planning. - A small Python module that subclasses
long_tamp.tasks.ManipulationTask, points aYamlTaskLoaderat that config, and defines aGRASP_SEQUENCE— the ordered list of(handle, target)pairs the phase graph should satisfy (target=Nonemeaning release).
plan.py (CubePickPlaceTask) is the reference implementation — copy it as a starting point. Everything
downstream of a manifest (replay.py, execute.py, the dashboard, calibration, execution tuning) is
task-agnostic: it reads a runs/<name>/ directory and doesn't know what produced it.
Step 1: confirm the pipeline with a TCP reach
Before authoring a task, run plan_tcp.py — it proves the whole pipeline end to end with no task-specific
setup. See Example: Cube Pick-and-Place for the command.
Step 2: build your task
The YAML config follows config/cube_pick_place.yaml's shape: object definitions (URDF/SRDF pair +
a handle with a <mask>, see below), the grippers involved, and freeze_joints for anything that stays
locked at its initial value for the whole plan.
The task module:
from long_tamp.config.yaml_loader import YamlTaskLoader
from long_tamp.tasks import ManipulationTask
_YAML_PATH = _HERE / "config" / "my_task.yaml"
_loader = YamlTaskLoader(_YAML_PATH)
class MyTask(ManipulationTask):
FREEZE_JOINT_SUBSTRINGS = [...]
def __init__(self, backend="pyhpp", viewer_type="auto"):
super().__init__(
task_name="my task", backend=backend,
FILE_PATHS=_loader.file_paths,
joint_bounds=_loader.joint_bounds_class,
viewer_type=viewer_type,
)
self.task_config = _loader.task_config
self.use_factory = True
def build_initial_config(self) -> list[float]:
return _loader.build_initial_config(objects=self.task_config.OBJECTS)
Then define the phase sequence:
GRASP_SEQUENCE: list[tuple[str, str | None]] = [
("so101/grasp", "cube/top"), # pick up at A
("cube/foot", "table/spot_b"), # dock at B
("so101/grasp", None), # release
]
Two gotchas that make a phase graph invalid:
- Don't narrow the loaded objects with
with_grasp_goals()-style filtering unless every object you need in the graph is also a grasp target —plan.pykeepstableinOBJECTSbecause it's only ever the handle side of a dock, never grasped, and filtering it out would removespot_bfrom the graph too. - Every object your gripper closes on needs an entry in
COLLISION_EXCLUSIONS(rooted at the hand's own subtree, e.g.wrist_roll) — grasping requires the fingers to occupy the same space as the object, so without the exclusion every grasp pose is invalid by construction.
Invoking it
While the task is new, use the generic escape hatch (no script changes needed):
./scripts/hpp_container.sh exec python3 -u -m soarm_tamp.plan_mytask --out runs/mytask01
Once stable, add a named subcommand to scripts/hpp_container.sh matching plan/tcp's shape, then:
./scripts/hpp_container.sh mytask --out runs/mytask01
Output is the same manifest format plan.py/plan_tcp.py write — replay.py, execute.py, and the
dashboard consume it unchanged.
5-DOF grasp constraint
The SO-101 has five arm joints, so a full 6-DOF grasp constraint (six equations) is solvable only where
the system happens to be degenerate. A handle should free one rotational DOF — the reference cube handle
carries <mask>1 1 1 1 1 0</mask>, freeing rotation about the approach axis, the DOF a parallel-jaw grip
doesn't care about for a symmetric object. A 600k-sample sweep found ~170° of usable yaw at every candidate
grasp spot with this mask, so it costs no reachability.
Reachable workspace
At x = 0.22 m with the hand vertical, IK solves for TCP z from 0.03–0.08 m and finds nothing at 0.10 m or above; tilting the approach 30° off vertical finds nothing at any of those heights at that radius. The verified top-down-reachable annulus (hand within 10° of vertical) is radius 0.10–0.30 m from the base axis — keep new task geometry inside it.
Execution tuning
Streaming a HPP-certified path to the servos open-loop isn't enough on its own — see
TAMP planning & execution, step by step § 6 for the full incident and the
--max-step/--servo-clamp/--sync-tol/--speed-scale knobs it produced. Keep --sync-tol above
--max-step so the arm always chases a target slightly ahead of it instead of stopping at every waypoint.
joint_test.py --single isolates one joint to check its URDF-to-servo mapping independent of those knobs.
Planning from where the arm actually is
python -m soarm_tamp.read_pose --port /dev/cu.usbmodemXXXX --out runs/start.json # host
./scripts/hpp_container.sh tcp --start runs/start.json --xyz 0.22 0.0 0.08 --out runs/tcp06 # container
A plan whose first waypoint is the URDF zero pose assumes the arm starts there — it usually doesn't. With
--start, the leg from the arm's actual pose becomes part of the validated trajectory instead of an
unchecked gap before it. plan.py takes the same flag.
Adding a dashboard tab for the new task
The dashboard's plan/replay/execute wiring is shared: every planning tab is a Panel built around one
PlanControls instance. A new tab follows tcp.py/pickplace.py's shape:
from soarm_sdk.dashboard.app import Panel
from ._common import Console, PlanControls
def build_mytask_panel(fk_update=None, fk_update_ghost=None) -> Panel:
def _build(server, ctx) -> None:
_build_mytask(server, ctx, fk_update, fk_update_ghost)
return Panel("My Task", _build)
def _build_mytask(server, ctx, fk_update, fk_update_ghost=None) -> None:
... # goal controls, a Plan button, PlanControls(...) wiring
Register it in soarm_tamp/dashboard/app.py's _register_plan_and_run(app):
from .panels.mytask import build_mytask_panel
app.register(build_mytask_panel(fk_update=app.fk_update, fk_update_ghost=app.fk_update_ghost))