Adding a planner
A planner is a Python package that tells TANDEM three things: what is in the scene (perceive), how to
reach one goal (plan), and how to run that plan on the robot while recording it (execute). TANDEM does
everything else: splitting the task into phases, teleop, checking a person's work and merging the legs.
Working examples: src/tandem/planners/tiptop/ (the built-in planner) and tests/toy_planner.py.
Quick start
Generate a package, install it where TANDEM runs, and run its tests:
tandem planners new shelfbot # creates ./tandem-shelfbot (--dir to change)
cd tandem-shelfbot
pipx inject tandem-tamp --editable . pytest # if tandem came from pipx
python -m pytest # passes as generated
tandem planners use shelfbot # the active profile now plans with it
The generated planner plans in a stand-in world, so its tests pass before you write any real code.
planners new prints the install command for your setup. With uv use
uv tool install --reinstall git+https://github.com/SamratSahoo/TANDEM.git --with-editable . --with pytest,
and in a virtualenv use pip install -e ".[test]".
Then:
- Fill in the
TODOs in the generated README, in order, keepingpytestgreen. - Once
executerecords real legs, setrecords_legs = Truein the test. - Check that
tandem plan --planner shelfbot --image workspace.png "<task>"splits a real task into phases your planner can do (Planning from a photo). - Run
tandem collect --no-execute. It perceives and plans without moving the robot.
Use tandem planners new NAME --sidecar if the planner needs its own environment (torch, CUDA, a camera
SDK). See Sidecars.
tandem planners list should now show shelfbot as no runtime needed. tandem planners info shelfbot
shows what TANDEM knows about it. What planners use changes in a profile is in
Planner settings.
Names
A planner's name uses lowercase letters, digits, _ and -, and starts with a letter.
A minimal planner
This is the whole shape of a planner, trimmed from the scaffold. It can move a block onto a tray.
import json, time, uuid
from pathlib import Path
from tandem.planners import (
Capabilities, ExecuteResult, Parameter, Planner, PlannerInfo, PlanResult, Predicate, SceneView,
)
ON = Predicate("On", (Parameter("obj", "movable"), Parameter("place", "surface")))
class ShelfBot(Planner):
info = PlannerInfo(name="shelfbot", display_name="ShelfBot", summary="Puts things on trays.")
CAPABILITIES = Capabilities(
name="shelfbot", # must equal info.name
goal_predicates={"On": ON}, # what a robot phase's goal may say
robot_description="move an object onto a surface",
goal_predicate_wire_names={"On": "on"}, # how plan() receives it
achievable_predicates=frozenset({"On"}),
reserved_predicate_names=frozenset({"On"}),
movable_type="movable",
surface_type="surface",
predicate_descriptions={"On": "{0} is resting on top of {1}"},
checkable_predicates=frozenset({"On"}), # a camera can judge On from a photo
)
def perceive(self, *, task_hint, save_dir, reset_arm=True, open_gripper=False):
scene_id = uuid.uuid4().hex[:8]
return SceneView(
object_labels=("block", "tray"),
table_label="table",
surface_labels=frozenset({"tray"}),
scene_id=scene_id,
rgb_path=str(Path(save_dir) / "perception_rgb.png"), # write a real image here
)
def plan(self, scene_id, goal, *, surfaces=frozenset(), movables=None, return_home=True,
save_dir, reuse_skeleton=None):
for atom in goal: # e.g. GoalAtom("on", ("block", "tray"))
if atom.predicate != "on":
return PlanResult(ok=False, failure_reason=f"cannot plan {atom.predicate}")
return PlanResult(ok=True, planning_seconds=0.0, plan_handle=uuid.uuid4().hex[:8],
task_plan=tuple(f"Place({a.args[0]}, {a.args[1]})" for a in goal))
def execute(self, plan_handle, leg, *, save_dir, should_stop=None):
start = time.time()
# ... run the plan and record robot_state.npz and the camera clips ...
Path(save_dir).mkdir(parents=True, exist_ok=True)
meta = {"trajectory_id": leg.trajectory_id, "segment_source": leg.segment_source,
"instruction": leg.instruction, "record_start": start, "record_stop": time.time(),
"fps": 15, "cameras": {}}
if leg.phase_index is not None:
meta.update(phase_index=leg.phase_index, n_phases=leg.n_phases,
phase_description=leg.phase_description)
(Path(save_dir) / "_meta.json").write_text(json.dumps(meta))
return ExecuteResult(ok=True, n_frames=0, rollout_dir=str(save_dir))
The rest of this page is the reference for each part.
The protocol
A session calls a planner's verbs in this order:
factory.create(ctx) → require_ready() → warm()
→ ( perceive() → plan() → execute() )* robot legs
→ release_hardware() … reacquire_hardware() around every human leg
→ home() → close() when the session stops
Subclass Planner, not tandem.planners.base.TampBackend. Planner supplies
every verb except perceive, plan and execute.
If a verb raises, the trial ends at that verb's stage. perceive and plan fail at tamp_planning, and
execute fails at tamp_execution.
| verb | what it must do |
|---|---|
capabilities() |
Return the capabilities. It must be static and cheap. |
require_ready() |
Raise RuntimeNotReady naming what is missing. |
warm() |
Open cameras, connect the robot and build solvers. It runs again after a verb raises, so it must be a no-op when already warm. |
perceive(*, task_hint, save_dir, reset_arm=True, open_gripper=False) → SceneView |
Look at the scene. task_hint is the whole instruction and only steers detection. |
plan(scene_id, goal, *, surfaces=frozenset(), movables=None, return_home=True, save_dir, reuse_skeleton=None) → PlanResult |
Plan one goal. If it can't, return ok=False with a failure_reason instead of raising. |
execute(plan_handle, leg, *, save_dir, should_stop=None) → ExecuteResult |
Run the plan and record one leg of leg.trajectory_id (contract). ok=False ends the trial at tamp_execution. |
capture_frame(*, camera="external") |
Save one RGB frame from camera (set by hitl.verification_camera) and return its path. |
release_hardware() |
Block until the robot and cameras are free for a person. |
reacquire_hardware() |
Take them back, wherever the person left the arm. |
home() |
Park the arm. Don't open the gripper. |
close() |
Release everything. It must be safe to call twice, and before warm. |
Notes on the arguments:
reset_armis true only on an attempt's first leg. Never park an arm a person just handed back.open_gripperis true only on the first leg after a human phase. Open the hand and move nothing else.goalis a list ofGoalAtom(predicate, args)in the wire spelling (for exampleon).surfacesis fixed for the whole task.
SceneView
| field | meaning |
|---|---|
object_labels |
Every object seen, except the table. |
table_label |
The support surface. Defaults to table. |
surface_labels |
The objects that are surfaces. |
scene_id |
An opaque id that TANDEM passes back to plan. |
rgb_path |
An image of the scene. TANDEM splits the task and checks robot-leg preconditions from it. Without it, proposing a plan fails and the trial ends at invention. |
detected_goal |
The planner's own reading of the instruction as GoalAtoms. It is the leg's goal when phase planning is off. |
Labels may change between perception passes. TANDEM rebinds its plan to the new labels.
PlanResult
Every field must be JSON-safe.
| field | meaning |
|---|---|
ok, failure_reason |
Whether it planned, and why not. |
planning_seconds |
How long planning took. |
plan_handle |
An opaque id that TANDEM passes back to execute. |
task_plan |
The operators with object arguments, e.g. ("Pick(bread)", "Place(bread, plate)"). It goes into hitl.json and is never parsed. |
artifacts |
Role → path of each file written. |
skeleton, skeleton_reused |
Used only with supports_skeleton_reuse. |
Capabilities
Capabilities are everything phase planning knows about your planner. They are checked when the class is defined. A minimal planner shows the required fields. A planner usually adds a few more, as the scaffold does:
CAPABILITIES = Capabilities(
..., # the required fields, as in the minimal planner
exclusive_arguments={"On": 0}, # an object rests on one thing at a time
moved_arguments={"On": 0}, # On's first argument is the object that moves
robot_operators=("Place(?obj: movable, ?place: surface)",),
one_pick_per_object=True,
supports_return_home=True, # only once plan() honours return_home=False
)
When a proposed phase breaks one of these rules, TANDEM sends it back to the model to repair ("repaired" below).
| field | meaning |
|---|---|
name |
Must equal info.name. |
goal_predicates |
Name → Predicate(name, (Parameter(name, type), …)), most important first. Robot phases can use only these. |
robot_description |
Required. One abstract sentence saying what the robot does. |
goal_predicate_wire_names |
How plan(goal=) spells each predicate, e.g. {"On": "on"}. Unlisted predicates are ones the planner supplies itself: they are dropped from goals, and a robot phase with only those is repaired. |
achievable_predicates |
What the planner's operators can make true, including every goal predicate. A robot phase asking for more is repaired. |
reserved_predicate_names |
Names the model may not invent, including every goal predicate. |
movable_type, surface_type |
The only two object types, and they must differ. Every goal-predicate and operator parameter uses one of them. |
moved_arguments |
Predicate → position of the moved object ({"On": 0}), which must be a movable_type parameter. Used for movables=, conjoining and wasted-move warnings. |
exclusive_arguments |
Predicate → the argument that can appear in only one atom at a time ({"On": 0}). It gives a free delete effect. A phase with two atoms in one slot is repaired. |
predicate_descriptions |
Templates shown to the operator and the camera check, e.g. "{0} is resting on top of {1}". Use {0}, {1}, … within the arity, and {{ for a literal brace. |
checkable_predicates |
Goal predicates a camera can judge from one photo. Invented predicates are always checkable. |
robot_operators |
Operator signatures, for the record only, e.g. Pick(?obj: movable). They may use only the declared types. |
prompt_fragments |
Prompt paragraphs by slot: placement_semantics, precondition_vocabulary, delete_effect_example, work_division, intermediate_state_example, robot_phase_rules. Missing slots get generic text. |
one_pick_per_object |
A plan picks each object at most once. Defaults to True, the safe choice. |
initial_state_is_clean |
Every goal starts from the same clean state, which allows conjoining. Defaults to False. Set it only if the solver guarantees it. |
supports_movable_restriction |
plan(movables=) is honoured: only those objects are picked and the rest are obstacles. A goal that moves another object returns ok=False. Needs moved_arguments. |
supports_return_home |
plan(return_home=False) is honoured: the arm ends where the last operation leaves it. TANDEM uses this on every leg but the task's last. |
supports_cooperative_stop |
execute polls should_stop between steps. Without it, a preempt aborts the trial once the leg ends. |
supports_skeleton_reuse |
plan can reuse a previous PlanResult.skeleton. |
TANDEM passes movables, return_home and should_stop only when the matching capability is declared.
So plan may leave movables or return_home out of its signature if it doesn't declare them.
Conjoining (hitl.conjoin_robot_phases) plans consecutive robot phases as one goal from one
perception pass. The atoms are sorted, so the proposal's order between them is lost. It needs
initial_state_is_clean. It stops before a phase that moves an object again (with one_pick_per_object)
or refills an exclusive_arguments slot.
The Planner base class
tandem.planners.Planner is an abstract base class, and it is also its own factory: the registry holds the
class itself.
| class attribute | required | what it is |
|---|---|---|
info |
yes | PlannerInfo(name, display_name, summary, homepage, requires, sources), shown by tandem planners list and info. requires is free text. sources defaults to the recipe's. |
CAPABILITIES |
yes | See Capabilities. |
recipe |
no | A RuntimeRecipe. None means pure Python. |
OPTIONS |
no | The task's settings: each key of a profile's planner.options, with a one-line description. |
RIG_OPTIONS |
no | Settings for this workstation, shared by all profiles: each key of planners.<name> in rig.yml, with a one-line description. A key can't be in both. |
A bad declaration raises one TandemError at import that lists every problem. For a shared base class,
pass abstract=True (class MyBase(Planner, abstract=True)). It is not checked and can't be registered.
What you get without writing it:
| member | default |
|---|---|
warm, close, home, release_hardware, reacquire_hardware |
Do nothing. |
require_ready |
Checks that the recipe's runtime is installed. |
capture_frame, move_to_joints |
Raise UnsupportedVerb. A session won't start without capture_frame when phase planning is on with hitl.check_human_effects, check_human_preconditions or check_tamp_effects. |
create(ctx) |
Runs validate_options(ctx.options) and validate_rig_options(ctx.rig_options), then cls(ctx). |
runtime_env(*, rig, settings=None) |
Returns {}. It is what tandem runtime run and shell add to the environment of the planner's own scripts. TiPToP's writes a tiptop.yml from the rig and points $TIPTOP_CONFIG at it. |
replay(rollout_dir, *, settings=None) |
Raises UnsupportedVerb, so tandem traj open isn't available. |
services(settings=None) |
Not defined, so the planner runs no helper servers. Define it to return servers such as TiPToP's M2T2 and FoundationStereo, each with name, title, runtime(settings) (a RecipeRuntime), url(), local(), healthy(), started_pid(), log_path, start() and stop(). tandem init builds their runtimes and tandem servers manages them; the planner starts them for a session itself (TiPToP's backend does it in warm and perceive). |
runtime(settings), runtime_root(settings) |
The runtime at ~/.local/share/tandem/runtimes/<name> (or $TANDEM_RUNTIMES_DIR). None without a recipe. |
Inside a planner you can use:
| attribute | what it is |
|---|---|
self.ctx |
The session's BackendContext: profile, session_dir, output_dir, execute, record, on_log, options, settings, session_id, task, events_file, runtime_dir, rig, rig_options. |
self.options |
The validated task settings. |
self.rig_options |
The validated machine settings. |
self.rig |
The rig: robot.host, robot.type, cameras, calibration_file(). |
self.log(text) |
Writes a line to the session log. |
Any tandem.planners.base.BackendFactory also works in place of a Planner subclass. It needs info,
capabilities(), create(ctx) and runtime(settings), plus any optional hooks. TiPToP is built this way.
Sidecars
Use a sidecar when the planner needs torch, CUDA kernels, a camera SDK or a robot client. The planner then
runs as a script in its own interpreter, and TANDEM talks to it over JSON lines. tandem planners new NAME
--sidecar generates both halves.
The TANDEM half declares the planner and says which script to launch:
# tandem_armsim/planner.py -- runs in tandem's process; keep it light
from tandem.planners import PlannerInfo, SidecarPlanner
class ArmSim(SidecarPlanner):
info = PlannerInfo(name="armsim", display_name="ArmSim", summary="...")
CAPABILITIES = CAPABILITIES # as for any planner
SIDECAR = "sidecar.py" # next to this module
recipe = None # its runtime; None runs the script with tandem's interpreter
TIMEOUTS = {"warm": 600.0} # seconds, overriding the defaults below
def warm_args(self):
# The sidecar can't read the rig, so send it what it needs.
return {**super().warm_args(), "host": self.rig.robot.host, **self.rig_options}
The sidecar half is the planner itself, with one method per verb:
# tandem_armsim/sidecar.py -- runs in the planner's own environment
from tandem_sidecar import log, serve
# isort: split
class World:
def warm(self, *, output_dir=None, execute=True, record=True, **planner_specific):
import torch # heavy imports go here, not at the top
log("armsim: warm")
return {}
def perceive(self, *, task_hint, save_dir, reset_arm=True, open_gripper=False):
return {"scene_id": "s1", "object_labels": ["block", "tray"], "table_label": "table",
"surface_labels": ["tray"], "rgb_path": f"{save_dir}/perception_rgb.png"}
def plan(self, *, scene_id, goal, surfaces, save_dir, movables=None, return_home=True,
reuse_skeleton=None):
# goal looks like [{"predicate": "at", "args": ["block", "tray"]}]
return {"ok": True, "planning_seconds": 0.1, "plan_handle": "p1",
"task_plan": ["Move(block, tray)"]}
def execute(self, *, plan_handle, leg, save_dir):
# record the leg and write _meta.json (see the recording contract)
return {"ok": True, "n_frames": 0, "rollout_dir": save_dir}
def capture_frame(self, *, camera="external"):
return {"path": "/tmp/frame.png"}
if __name__ == "__main__":
raise SystemExit(serve(World()))
Rules for the script:
- Don't print to stdout. TANDEM uses stdout to talk to the sidecar. Importing
tandem_sidecarredirects stdout to stderr, which goes to the session log, so import it first and put# isort: splitafter it. - Import heavy libraries inside
warm(). Then a library that fails to import shows up as a failed warm-up with its error, instead of a sidecar that never starts. The conformance kit checks the import order. - Import only
tandem_sidecarfrom TANDEM. It is one standard-library file (Python 3.8+), and TANDEM puts it on the script'sPYTHONPATH. - Append to
PYTHONPATH; don't replace it. If your pixi environment setsPYTHONPATH(for example in[activation.env]), the sidecar dies withNo module named 'tandem_sidecar'. To run a sidecar by hand, put TANDEM'splanners/sidecar_kitdirectory onPYTHONPATHyourself.
Each method takes keyword arguments and returns a JSON-safe dict. capture_frame returns {"path": ...}.
A verb with no method gets Planner's default. A sidecar missing perceive, plan or execute is refused
at warm. If a method raises, TANDEM reports "<verb> failed -- <Type>: <message>" and puts the traceback
in the session log. The wire protocol is documented in the docstring of
src/tandem/planners/sidecar_kit/tandem_sidecar.py.
What TANDEM does with a sidecar
| behaviour | detail |
|---|---|
| Launch | Runs the runtime's python with pixi run (or TANDEM's interpreter without a runtime), from the runtime's working directory, in its own process group. |
| Optional arguments | movables, return_home and reuse_skeleton are sent only when declared. Passing an undeclared one is an error. |
| Output | Logs and stderr go to the session log. Events go to the events file (through on_event). |
| Crash or timeout | TANDEM sends SIGTERM, then SIGKILL, to the process group. The trial ends at that verb's stage, and the next warm() restarts the sidecar. |
| Cooperative stop | If declared, TANDEM polls should_stop and signals the sidecar through the file named in TANDEM_SIDECAR_STOP_FILE. |
close |
Asks the sidecar to quit, then ends its process group, helpers included. |
Default timeouts
| verb | seconds |
|---|---|
warm |
900 |
perceive |
300 |
plan |
900 |
execute |
1800 |
capture_frame, home, release_hardware, reacquire_hardware |
180 |
SidecarPlanner methods you can override
| method | what it controls |
|---|---|
launch_command |
The command that starts the sidecar. |
launch_cwd |
Its working directory. |
launch_env |
Its environment. |
warm_args |
What warm sends. Defaults to output_dir, execute and record. |
on_event |
What happens to each event the sidecar sends. |
call(verb, **args) |
Not an override: calls a sidecar-only verb. |
The tandem_sidecar kit
| function | what it does |
|---|---|
serve(handlers, verbs=None, on_exit=None) |
Answers requests until TANDEM says quit or closes stdin, then calls on_exit or the close handler. handlers is an object with one method per verb, or a verb → callable mapping. |
log(message, level="info") |
Writes to the session log. Safe from any thread. |
event(name, **fields) |
Sends an event. The fields can't be named id, log or event. |
should_stop() |
True once TANDEM asks for a cooperative stop. |
A runtime recipe
A planner that needs more than pip declares its runtime as data. tandem planners install NAME builds it,
tandem planners list shows whether it is current, and tandem planners remove NAME deletes it. Here is a
recipe with one pinned source, a pixi environment and one build step:
from tandem.planners import BuildStep, PixiEnvironment, RuntimeRecipe, Source, SourcePin
RECIPE = RuntimeRecipe(
planner="myplanner", # must equal info.name
title="MyPlanner",
sources=(
Source(
SourcePin("mysolver", "https://github.com/me/mysolver.git",
"4db8f92c1e0a4b1f9d2e3c4b5a6978877665544a", ref="main"),
marker="pixi.toml", # a file that exists only in a complete tree
),
),
environment=PixiEnvironment(manifest="mysolver/pixi.toml"),
steps=(
BuildStep(name="kernels", task="build-kernels", produces=("mysolver/build/*.so",),
description="compiling the CUDA kernels"),
),
)
class MyPlanner(Planner):
recipe = RECIPE
...
TiPToP's full recipe is src/tandem/planners/tiptop/recipe.py. What it installs is described in
The planner runtime.
| part | fields |
|---|---|
RuntimeRecipe |
planner (equals info.name), title, sources, environment, steps, assets, notes. |
Source |
SourcePin(name, url, commit, ref=); trim: paths deleted after fetching; patches: applied in order, and a failure stops the install; marker: a path present only in a complete tree; persistent: directories written at run time that are kept across tree replacements. |
PixiEnvironment |
manifest: the planner's pixi manifest and lock, inside a source; home: where the environment lives, default env, outside every tree; env: environment variables. |
BuildStep |
name; task (a task in the manifest); env; produces: globs that exist once it has run; description; optional: the runtime works without it, so a failure doesn't fail the install and shows as a note; requires: absolute paths it needs, such as an SDK's installer, and it is skipped until they exist; missing: what won't work meanwhile, and the fix. TiPToP's ZED step uses the last three. |
Asset |
(source, dest): a file from your package copied into the runtime. |
How it behaves:
- Pins are full 40-character commits.
ref(the branch) is shown bytandem planners infoandtandem runtime status, but never compared. - Fetching is a shallow
git fetchandgit archiveof the commit. If that fails it tries therefbranch, then GitHub's archive without git. The result is always checked against the pin. - Layout: one directory per source, plus
env/(the environment, linked as.pixibeside the manifest),cache/(thepersistentdirectories) and.tandem-runtime.json(what is installed). - Moving a pin replaces that tree without re-solving the environment.
persistentdirectories survive. The installer never deletes a git checkout, and replaces unlisted trees only where.tandem-runtime.jsonexists. - Status compares
.tandem-runtime.jsonwith the recipe. A moved pin showsoutdated, with the command to rebuild.
Other details:
- Placeholders work in
envvalues:{root},{source:NAME}(a tree's path),{commit:NAME}, and{version:NAME}.{version:NAME}is the commit as a PEP 440 version (0.0.0+g4db8f92), for packages that take their version from git. - Offline installs use
--sources DIR,$TANDEM_PLANNER_SOURCESortandem planners bundle(Offline install). - ffmpeg: merging legs uses the environment's
bin/ffmpeg, or the one onPATH.
Options and doctor rows
A planner has two kinds of settings. Task settings live in each profile's planner.options and are declared
in OPTIONS. Machine settings, such as a server's address or a robot's ports, live in rig.yml under
planners.<name> and are declared in RIG_OPTIONS.
from pydantic import BaseModel
from tandem.core.errors import TandemError
class _Options(BaseModel, extra="forbid"):
speed: float = 0.5
scene_file: str | None = None
class MyPlanner(Planner):
OPTIONS = {"speed": "arm speed, 0-1", "scene_file": "the scene to load"}
RIG_OPTIONS = {"server_url": "the solver server's address"}
@classmethod
def validate_options(cls, options):
opts = _Options(**(options or {})) # a ValidationError is reported by key
if opts.scene_file is None:
raise TandemError("planner.options.scene_file is required")
return opts.model_dump(mode="json") # plain data only
People set them like this:
tandem planners use myplanner --option scene_file=kitchen.yml # a task setting
tandem rig set planners.myplanner.server_url http://HOST:9000 # a machine setting
TANDEM calls validate_options(options) when a profile loads, and validate_rig_options(options) when the
rig is read. The defaults refuse any key that isn't declared, suggest the nearest one, and say which file a
misplaced key belongs in. If you override either one, it must:
- Accept its own output, because the result is validated again on every read.
- Return plain data: string-keyed mappings, lists, strings, numbers, booleans and
None. Usemodel_dump(mode="json"), neverPath,Enumor numpy values. - Raise
TandemErrororValueError(pydantic'sValidationErrorcounts). TANDEM reports it underplanner.options.orplanners.<name>..
To make a setting required, refuse {} with a TandemError naming it. Put a machine setting in
validate_rig_options and a task setting in validate_options. tandem init fills a planner's machine
settings with whatever validate_rig_options({}) returns.
What people see
describe_options(profile, *, settings=None) returns an OptionsView with summary, sections,
receives, receives_note and warnings. tandem profile show, the web editor and the session header show
it. By default it lists each option as set. tandem profile show NAME --planner prints receives, which is
exactly what the planner gets.
Doctor rows
doctor_checks(profile, *, settings=None, probe_hardware=True) adds rows to tandem doctor. It returns
tandem.core.probe.Check(name, state, detail, hint, group) rows, and by default returns none.
stateisprobe.OK,WARN,FAILorSKIP.- With
profile=None(the preflight oftandem init), check only the machine. - With
probe_hardware=False(--no-hardware), touch no network or bus. - A FAIL stops a session. It also stops
tandem initbefore it builds the runtime (interactively, it asks). - TANDEM already reports the runtime, so don't repeat it.
Registering it
The scaffold registers the planner with an entry point in pyproject.toml:
[project.entry-points."tandem.planners"]
arm = "tandem_arm.planner:ArmPlanner" # a Planner subclass, or any BackendFactory
Then select it with tandem planners use arm, or planner: {backend: arm} in a profile.
tandem planners default arm makes it the planner new profiles get (Commands).
TANDEM looks up a name in this order, and the first match wins:
tandem.register_backend(name, factory)(aliasregister_planner), called at runtime.factorymay be a lazily imported"module:attribute"string. A taken name is an error unless you passreplace=True.- The built-in planner, TiPToP.
- The
tandem.plannersentry point.
tandem planners list, info and profile loading all import plugins. Keep the entry-point module light,
and import torch and robot clients in warm() or a sidecar.
When something goes wrong:
- A plugin that fails to import is listed as
broken, with its error. Everything else still works, and a profile naming it still loads, with its options unchecked. - A plugin shadowed by a registered or built-in planner is listed with the reason.
- Two installed packages claiming the same name is an error.
The recording contract
execute records one leg of trial leg.trajectory_id. TANDEM's merge orders a trial's legs by their
recording windows and joins them into one episode. When leg.record is set, save_dir must hold three
things: _meta.json, robot_state.npz and the camera clips.
save_dir/
├── _meta.json
├── robot_state.npz
├── external_cam.mp4 external_cam_2.mp4 and hand_cam.mp4 too, if recorded
└── ... anything else is the planner's own
_meta.json:
{
"trajectory_id": "3f9c0a1b2d4e5f60", "segment_source": "tamp",
"instruction": "place the bread inside the box",
"phase_index": 0, "n_phases": 2, "phase_description": "put the bread in the box",
"record_start": 1790270001.2, "record_stop": 1790270037.4, "fps": 15,
"cameras": {"exterior_image_1_left": "external_cam.mp4", "wrist_image_left": "hand_cam.mp4"}
}
| key | value |
|---|---|
trajectory_id, segment_source |
Copied from leg ("tamp" for a planner leg). The merge finds the leg by them. |
instruction |
leg.instruction: the whole task, which becomes the dataset's language label. |
phase_index, n_phases, phase_description |
Copied from leg when leg.phase_index is not None. |
record_start, record_stop |
Epoch seconds. Legs are ordered by them. |
fps |
Frames per second. |
cameras |
Dataset key → clip file, e.g. {"exterior_image_1_left": "external_cam.mp4"}. |
plan_file (optional) |
The saved plan's bare file name (default tiptop_plan.json), for tandem traj show and the web UI. |
robot_state.npz holds each array in tandem.core.merge.STATE_KEYS, one row per frame, and optionally
action_joint_velocity ([F,7]). Nothing else may be in it. cmd_* arrays are commanded values and the
rest are measured.
| array | shape |
|---|---|
joint_position, cmd_joint_position, cmd_joint_velocity |
[F,7] |
gripper_position |
[F], in [0,1] |
cmd_gripper |
[F], binary. The export skips an episode where it isn't. |
frame_time |
[F], wall-clock time, float64 |
Record measured arrays from the robot, not copies of the commands. Otherwise a policy trained on the data learns to echo its commands.
Camera clips: every clip that cameras names must exist, and there must be at least one even if
cameras names none. Name each one external_cam.mp4, external_cam_2.mp4 or hand_cam.mp4, whatever its
dataset key. Only those names are merged, viewed and exported. Only cameras that every leg recorded are
joined.
tandem.core.trajectories.is_complete(leg_dir) checks a leg against this contract. Any other files belong to
the planner, and the merge copies the first planner leg's into the episode. The merged layout is in
Episode layout.
Edge cases:
- Write
_meta.jsoneven when execution fails part-way or records nothing (n_frames=0, as the scaffold does). Otherwise the leg becomes an episode of its own. Always returnrollout_dir(usuallysave_dir) andn_frames. - Return
ExecuteResult(stopped_early=True)when you honoured a cooperative stop (a preempt or a session stop). The leg then never advances the plan, whateveroksays. After a preempt the trial is filed as aborted. A stop nobody asked for is atamp_executionfailure.
The conformance kit
tandem.planners.testing runs the whole protocol against your planner with no GPU, robot or session. Your
package's test subclasses it:
from tandem.planners.testing import PlannerConformance
from tandem_arm.planner import ArmPlanner
class TestArmPlanner(PlannerConformance):
planner = ArmPlanner # a Planner subclass, a BackendFactory or a registered name
records_legs = True # False checks only the _meta.json stamp
options = {} # its planner.options
rig_options = {} # its machine settings (rig.yml planners.<name>)
task_hint = "put one thing where it belongs"
It tests the declarations, options, machine settings, doctor rows, sidecar script, lifecycle and every verb.
With records_legs, it also tests the recording. It skips anything the planner doesn't declare or ship.
The backend is always built for a stand-in machine (stand_in_rig): the default arm at 172.16.0.2, a hand
camera and an external camera, and no extrinsics. It never reads your rig.yml. So a planner that
reads self.rig.robot.host runs, and the tests don't depend on the machine.
Two switches hold the planner to what phase planning needs. Both are on by default, and the scaffold passes both with stand-in images. Turn one off only for a planner that is never run with phase planning.
| switch | requires |
|---|---|
phase_planning = True |
Every scene's rgb_path opens as an image. |
verifies_human_phases = True |
capture_frame(camera="external") returns an image. |
Hooks to override:
| hook | use it to |
|---|---|
rig(tmp_path) |
Test against a different stand-in rig. |
goal(scene, caps) |
Supply a plannable goal when the kit can't guess one. |
make_backend(tmp_path) |
Build the backend differently. |
Each check is also a plain function that raises ConformanceError: check_declarations, check_protocol,
check_scene, check_plan_result, check_leg and check_sidecar_script.