A task is one file in cartesian_hand/tasks/. --task squeeze imports
tasks/squeeze.py and calls its build(). Start with the example below, then
read the section for the kind of task you are writing. The reasons behind the
design are collected in Design notes at the end.
See also internals.md for the engine those tasks run on, and hardware.md for the hand they run on.
Terms used on this page:
- row: one step of a task, such as "move here", "close until contact", "hold" or "twist".
- env: one of N copies of the hand run as a batch. Hardware is N=1, and the GPU sim runs thousands. Every tensor has N as its first dimension.
- standing order: the command a joint keeps sending after its row has finished. It is what keeps a grip squeezed while later rows run.
- datum: the hand's zero, which the
zerotask sets.
# cartesian_hand/tasks/squeeze.py
"""Close the base jaw on an object, then hold it 2 mm past contact."""
from dataclasses import dataclass, field
from ..config import BASE_JAW
from ..primitives import Hold, Probe, Sequence
@dataclass
class Config:
label: str = "Squeeze" # button text on the studio page; "" for none
grip_torque: float = 300.0 # 0-1000, like every torque in this repo
bite_mm: float = field(default=2.0, metadata={"tune": (0.0, 4.0)}) # a slider
def build(hand, start_mm, cfg=None, **kwargs):
cfg = cfg or Config()
return Sequence([
Probe(label="probe", group=BASE_JAW, creep=True,
measure={"contact_mm": BASE_JAW}), # close until it stalls
Hold(label="grip", group=BASE_JAW, effort=cfg.grip_torque / 1000,
goal=lambda m: m.contact_mm - cfg.bite_mm), # press bite_mm further
], hand=hand, start_mm=start_mm,
travel_torque=50, approach_torque=150, approach_speed=800)Save the file and it works straight away, with no registration step:
python -m cartesian_hand.sim --task squeeze # runs; fails at the probe (no object in the model)
python -m cartesian_hand.studio --hand my_hand --task squeeze # zero the hand firstThe studio page also gets a "Squeeze" button, and bite_mm becomes a slider in
the tune a task panel. tasks/base_grasp.py is the same two rows with the
bench measurements behind each number.
A task module provides two names:
build(hand, start_mm, cfg=None, **kwargs)is required.start_mmis[N, J], the current position of every joint in millimetres. It returns the controller.Configis an optional dataclass of defaults.labelputs a button on the page (""means no button).sets_datum = Truemakes the task's result the hand's new zero. Any field withmetadata={"tune": (lo, hi)}becomes a slider with that range.
Goals are absolute in the hand's millimetre frame, so zero the hand before running a task that uses them. Without calibration, 0 mm is wherever the hand was at startup.
build returns a primitives.Sequence: a list of rows that runs top to bottom,
separately in every env.
| row | what it does | finishes when |
|---|---|---|
Move(goal={dof: mm}) |
drives DOFs to a goal | every DOF is within tolerance_mm (1 mm) |
Probe(group=dofs, measure={"name": dof}) |
closes DOFs until they touch, and records where | contact is confirmed. Reaching goal (default 0, fully closed) without contact is a failure |
Hold(group=dofs, goal=..., seconds=...) |
changes a standing order, or keeps every one for seconds |
seconds have passed, or at once if none are given |
Twist(jaw=, left=, right=, radius=, span=, ...) |
one release, reset, re-grip and turn stroke | the stroke is done; count= repeats it |
Loop(count=..., rows=[...]) |
repeats rows; count may differ per env |
Fields every row accepts:
effort: torque cap, normalized 0-1 (a torque of 300 is0.3). With none, the row uses the task's travel effort, or its approach effort for aProbe.creep: move at the slow contact speed instead of full speed.loaded: this row pushes an object, so its effort never drops below travel effort.seconds: deadline override. By default a deadline is derived from the distance and the commanded speed, timestimeout_margin(1.5).label: shown in the trace. Nothing reads it.
Probe(grip=...) latches that effort the tick contact is confirmed, and the
grip stays on as a standing order, so later rows never restate the clamp. In
cap the base jaw keeps squeezing while the aux jaw twists and extracts.
A goal is a number, a tensor, or a function of earlier measurements.
measure={"contact_mm": BASE_JAW} on a row stores where that DOF stopped, and a
later row reads it as lambda m: m.contact_mm, an [N] tensor. Reading a name
that no earlier row stored raises an error that lists the names stored so far.
Rules that catch people out:
- A row that fails ends the task for that env. A row fails when it misses
its deadline, or when a
Probecloses all the way without touching anything. Every later row is skipped for that env. - Efforts have a floor. Travel and approach efforts are raised per DOF to the
hand's
torque_min_to_move, because below it a joint does not move at all. An expliciteffort=is used exactly as given (unless the row isloaded), so an explicit effort below the floor leaves the joint where it is until the deadline. See Effort floors. - Free moves run at the servo's rated top speed.
Sequenceignorestravel_speedand the hand'sspeedgains for them. Usecreep=Trueorspeed_scale=to go slower. Contact closes at the task'sapproach_speed, and each kind of row gets its own deadline, so slow probing does not make ordinary moves wait on a contact-sized timeout. - z carries gravity. To lift the stage while it holds something, use
primitives.lift_effort(hand)as the effort.
tasks/cap.py is the full-size example, with probes, a Loop, two Twist
strokes and a z press. Its outline is under Direct policies.
To change a task's numbers without copying it, write a new file next to it and reach what it reuses through the module:
# tasks/cap_gentle.py
"""Squeeze 80 -> 40: hand_2 crushed a PET cap on stroke 2 at 80."""
from . import cap
def build(hand, start_mm, **kwargs):
return cap.build(hand, start_mm, cfg=cap.Config(squeeze_torque=40.0), **kwargs)--task cap_gentle works immediately. Because Config is a dataclass,
dataclasses.replace stacks one variant onto another.
Write from . import cap, not from .cap import Config. The second form
binds Config into the variant's own namespace, which is where tasks.config()
looks. The variant would then report cap's label and add a second "Cycle cap"
button to the panel. To give a variant its own button, define a Config in the
variant, as tasks/child_safe_cap.py does.
Record bench results in the module docstring, so each result is versioned next to the task variant it describes.
A task can also be a generator of fixed Motions programs instead of a
Sequence. Use this form when the procedure is a fixed schedule whose outcomes
do not feed later rows, and for zeroing. Each yield hands a program to the
executor and evaluates to the [N, J] millimetres measured when it finished.
The task's return value ends up in TaskRunner.result:
def build(cfg, start_mm, **kwargs): # tasks/zero.py, the whole module
here, stops = start_mm, start_mm.clone()
alive = torch.ones(len(start_mm), dtype=torch.bool)
for dof_ids, name in PHASES:
seek = program({d: Move(here[..., d] - OVERTRAVEL_MM, creep[d],
"stuck", SEEK_TIMEOUT_S) for d in dof_ids})
here = yield seek
alive &= seek.succeeded()[:, dof_ids, 0].all(dim=1)
stops[:, dof_ids] = here[:, dof_ids]
mid = here[:, dof_ids] + travel[dof_ids] / 2 # per DOF, from the stop
here = yield program({d: Move(where(alive, mid, here)[..., d], PARK_TORQUE,
"goal", PARK_TIMEOUT_S) for d in dof_ids})
return Result(stops, alive)tasks/ready.py is a shorter example, two steps built with Program directly.
Report failure; do not raise it. Result.ok is [N], one flag per env. The
caller decides what a failure means: sim.run raises, studio.finish does not
save the calibration, and a batched trainer masks the bad envs. The task's only
job is to keep every goal it derives from a failed measurement bounded. In
tasks/zero.py a failed env parks where it is instead of at
mid-rail + travel/2, because the far end of the rail is open.
A measurement can set a value in a later program, but not the number of
steps. The Python loop that builds a program runs once, before the program
starts, so the program's length is fixed. Envs that need fewer strokes carry
when=False on the extra steps and idle through them. A task whose step count
depends on what it measures, such as cap, should be a Sequence.
Sequence is one implementation of the backend-neutral Policy contract in
policy.py. Each tick a policy receives an Observation (position_mm,
velocity_mm_s, contact, elapsed_ticks) and returns an Action (goal_mm,
max_speed_mm_s, and effort_limit normalized to 0-1). Every field is a tensor
with N first, and hardware is N=1 on the same API.
Envs may be in different rows and use different parameters in the same
rollout. Sequence.step has no Python branch on a measurement and never copies
a tensor to the host.
Policy state and parameters are typed dataclasses with tensor leaves. An
optimizer may own a packed theta[N,P], but it converts that once to a
task-specific dataclass such as CapParameters, and policy code never indexes a
parameter by string. Measurements may change values, masks, phases and tensor
counters, but never a Python loop bound or a tensor shape.
Five manipulation tasks are Sequences transcribed from an earlier internal
implementation (not included in this repo) that ran on real objects. The
sequences are validated on hardware. These implementations of them have not
been run on the objects.
| task | from | what makes it its own file |
|---|---|---|
cap |
caps_contact_based.py |
the canonical one; probe, twist, extract, re-thread, present |
screwdriver |
manual_screw_driver.py |
both jaws hold one tool; the base jaw is taut-contact only and never squeezed. cw selects reverse + press |
pipette |
pipetting.py |
longest sequence; a twist-lock knob, then plunge and draw as one shared push-to-stall stroke |
syringe |
syringe.py |
no twist at all; the repeating DOF is z, and the jaw re-grips the plunger higher each stroke |
scissors |
scissor_type.py |
no twist either; z travel drives the tool's pivot. Both jaws stay squeezed at the end |
cap as rows:
Move height z down to the cap's top face
Probe probe close both jaws until stuck; measure the cap's radius
Hold grip keep squeezing the bottle, from here to the end
Loop cycles
Twist open unscrew. strokes = ceil(2*pi*r / finger span)
Move release open the aux jaw to radius + clearance
Move centre fingers to mid-span
Hold centre hold 0.2 s, so profiled travel cannot overlap the re-grip
Probe regrip close the aux jaw onto the cap
Move lift z up, carrying the cap
Move cap clear fingers closed, cap held above the bottle
Hold cap clear wait
Move cap align fingers back to mid-span
Move put back z down to thread height
Twist close the same stroke mirrored, pressing z down as it turns
Move let go open the aux jaw
Move present fingers home
The stroke count is the cap's circumference divided by how far one finger can slide. It is known only after the jaws have closed on the cap, and each env can have a different one.
Both CLIs run a named task the same way. The sim command is wired correctly but cannot complete until the model has a bottle and cap:
python -m cartesian_hand.studio --hand hand_2 --task cap
python -m cartesian_hand.sim --hand hand_2 --task capIn Python, the same policy object goes into every executor.
tasks.make("cap", hand, start_mm) builds it, and tasks.make("zero", ...)
returns zero's fixed-program controller the same way:
studio.live(hand="hand_2", policy=policy)
state = sim.run(policy, cfg)
state = sim.run_warp(policy, cfg, n_envs=N, device="cuda")python -m cartesian_hand.studio has two task tools, both in the tasks
window on the left of the page.
tune a task: pick a task, drag its numbers, and save the result as a
variant. The sliders come from the tune ranges on the task's Config fields.
TASK TIMELINE: open on every studio page, including watch-only mode. It is
a list of rows you can edit like a CAD history. Select a row
and its parameters load into the editors. Apply an edit in place, Move
up/down, Delete, or Insert after. A row is one action (joints, goal,
torque, and a finish condition of goal, stuck, wait or external
completion), optionally gated on an earlier stop outcome or position of a source
joint. Save the result as a new task file.
Load task fills the timeline from the task selected in the tune dropdown,
so you can start from an existing procedure. Tune changes the numbers a task
declared and keeps its structure. Load reaches everything a row has (joints,
stop rule, frame, condition), none of which is a Config field, and gives you
back a flat program to save under a new name. Two kinds of task cannot load,
and both say so: a Sequence (cap) has no fixed steps to show, and a
multi-program task (zero, scissors) has only its first program before it
runs, because the rest are built from measurements it has not taken yet. Goals come back as the
numbers the task computed at the hand's current pose.
Run to row runs rows 0 to the cursor on the hand and leaves it there, so
you write the next row from the pose the earlier rows actually produced. Use it
for frame="here" distances, which you can only choose from the pose itself.
It writes tasks/_preview.py (rewritten every run, gitignored) and runs that
file by name, like a task button.
Both tools save cartesian_hand/tasks/<name>.py through compose.py. The saved
file does not depend on the studio, so it runs under sim.run and on the bus
without the studio.
Inside these fixed programs, feedback works per env. Each successful row
records stopped_at and stopped_ok, and a later row evaluates its When(...)
condition once, when it starts. A timeout invalidates the outcome without
overwriting the last trustworthy position, and a row disabled by its condition
keeps the standing order.
One state machine. The row logic lives once, in Sequence in
primitives.py. When each task had its own controller file, one deadline bug
got copy-pasted into all six.
build is the whole procedure. In a generator task the phases are yields
inside build. Splitting them into probe_program, stroke_program and
extract_program called from a fourth function would give one procedure four
names and one more place for the phase order to disagree with itself.
No registry. The file stem is the task name, and importing the file is the
whole dispatch. label and sets_datum are Config fields rather than
LABEL / SETS_DATUM module constants, so a task has one configuration object.
Constructing a Config runs no program, so the page lays out its buttons before
any task exists. Buttons are opt-in so that variants do not fill the panel.
Failure is data. Reducing the outcome with .all() and raising would let
one env out of 4096 discard the 4095 that succeeded, and the exception would
unwind the generator so they could not be recovered. Under domain randomisation
some envs are supposed to fail.
zero and tilt stay generators. zero's uncalibrated negative overtravel
must not pass through the clamp that studio.live applies to every policy goal.
Nothing in tilt depends on a measurement.
Load reads the built program, not the source (compose.rows_from, the
inverse of program_source). A parser would only understand files compose.py
wrote, and would misread every hand-written task, whose goals are expressions
over a Config rather than literals.
Run to row executes the file. The preview goes through the same path as every other task, so no bug can exist only in the preview.
The panel is not in the execution path. It writes the file and is done, so
the result runs under sim.run at N=4096 and on the bus without importing the
studio. Dependencies go studio → compose → tasks, never back.
There is no automatic tuner. The repo used to ship one, search.py: random
search over the declared tune ranges, ranked on (row success, ticks), with
the winner written as a variant. We deleted it because it optimised against a
simulator that cannot see most of what it sampled:
- MuJoCo ignores torque.
sim.profiletakes the goal and nothing else, so every*_torquefield is invisible there. That is 1 ofzero's 2 knobs, 3 ofcap's 9, and half of every composed task's, since each row emits agoal_iand atorque_i. The search sampled them anyway, and they showed up in the winner's report as if they were findings. - The only gradient it found was trivial. Score rose with
timeout_marginand with nothing else, which only means a longer budget lets more rows pass. - It disagreed with the bench, and the bench was right. Sim scored
zero's shipped defaults at 0.50, while the hand_1 log intasks/zero.pyshows the same defaults reproducing the datum to 0.074 mm with no phase expiring.--save-aswould have written a variant that slows real zeroing by about 70% to fix a simulator artifact, and sent it to hardware.
tasks.tunables() stays, because the sliders read it. A tune range is a
range a person may drag, not a claim that the number can be
measured. A tuner would need objects in the model, torque that reaches the
actuators, and a score based on task success rather than execution success.
contacts_alive, sweep_at_least and contact_force_above in the
manipulation specs show what that score would look like. Until then, use
Run to row with a person watching.