Defining a Task
A task describes what the arm should do and the run conditions for it. In a
scenario it is the [task] table; in code it is a Task (API).
A task does not move the arm on its own — a controller
reads the task (its target and parameters) and produces the joint torques. This
guide covers configuring the built-in task and defining a new task type of your own.
For the full [task] schema (target, duration, dt, schedule, tolerance,
enforce_limits) see Control Configuration.
What a task carries
| Field | Meaning |
|---|---|
type |
The task kind — a label a controller interprets (required: the discriminator that decides what else the task needs). |
target |
The task-space goal (x, y) in meters. Required for reaching; other task types may omit it. Drawn as a marker by the tools. |
duration / dt |
Run conditions: total simulated time and the fixed control step. |
schedule |
Time scaling for planned trajectories (minimum_jerk, quintic, …). |
tolerance / label / color |
Success radius and marker presentation. |
enforce_limits |
Whether the joint limits act as a dynamics hard stop (see Joint Limits). |
params |
Any extra [task] keys, kept verbatim — how a custom task carries its own data. |
type is the only universally required key. The built-in types are reaching,
multi_target_reaching, periodic_curve, trajectory_tracking, and
joint_trajectory_tracking (see
Control Configuration); only reaching (and
the multi-target variant) requires a target. Because the task type is a label that
a controller interprets, "defining a new task" means two things together:
registering the type so configs validate, and giving a controller the logic
that reads the task's target / params (or supplying a joint reference for the
tracking controllers — see below).
Option 1 — configure the built-in reaching task
Most goals are a reaching task with different targets / run conditions; no code is
needed. See the Control Configuration
guide and override the [task] per run with tools/reaching_simulator.py --task.
Option 2 — define a new task type
Use this when the goal is not a single reaching point — for example tracing a circle, where the task needs a center, radius, and period.
Periodic curves are built in
Tracing a closed curve (circle, ellipse, lemniscate, rose, …) is already provided
by the periodic_curve task type
(Control Configuration); register a new
curve with skelarm.register_curve rather than a whole task type. The worked
example below is kept as a from-scratch illustration of the task + controller pattern.
1. Register the type and carry parameters
Allow the new type with register_task_type. Any [task] keys beyond the
recognized ones are preserved on task.params, so your type carries its own
configuration:
[task]
type = "tracing"
target = [1.2, 0.0] # optional for a custom type: a marker point (here the path's start)
duration = 6.0
dt = 0.002
# custom keys -> task.params
center = [0.8, 0.0]
radius = 0.4
period = 3.0
from skelarm import Task, register_task_type
register_task_type("tracing") # now [task].type = "tracing" validates
task = Task.from_dict({
"type": "tracing",
"target": [1.2, 0.0],
"center": [0.8, 0.0], "radius": 0.4, "period": 3.0,
})
assert task.params["radius"] == 0.4 # extra keys land here
Only type is required; a custom type may omit target entirely (it then loads as
None). Including one is handy — the GUI and plots draw it as the marker — so set
it to a representative point (here the path's start). Controllers that need a point
goal call task.require_target(), which raises a clear error if it is absent.
2. Give a controller the task's logic
A task type only has meaning through a controller that reads its params. Pair it
with a custom controller (see Defining a Controller),
whose builder receives the Task:
import numpy as np
from skelarm import Controller, compute_jacobian, register_controller
class CircleTracer(Controller):
"""Pull the tip along a circle parameterized by the task's params."""
def __init__(self, center, radius, period, *, k_task=200.0, d_joint=20.0):
self.center, self.radius, self.period = np.asarray(center, float), radius, period
self.k_task, self.d_joint = k_task, d_joint
def control(self, t, skeleton):
phase = 2.0 * np.pi * t / self.period
goal = self.center + self.radius * np.array([np.cos(phase), np.sin(phase)])
tip = skeleton.links[-1]
force = self.k_task * (goal - np.array([tip.xe, tip.ye]))
return compute_jacobian(skeleton).T @ force - self.d_joint * skeleton.dq
def build_circle_tracer(params, skeleton, task):
return CircleTracer(task.params["center"], task.params["radius"], task.params["period"],
k_task=params.get("k_task", 200.0), d_joint=params.get("d_joint", 20.0))
register_controller("circle_tracer", build_circle_tracer)
A scenario then wires the two together and runs reproducibly:
from skelarm import load_scenario, run_scenario
log = run_scenario(load_scenario("tracing.toml"))
log.save("tracing.sklog.npz") # replay with tools/player.py
Trajectory-tracking tasks (reusing the controllers)
The example above gives a custom controller the task's logic. The other route is to
give a tracking controller (computed torque, joint PD, inverse-dynamics PD, MPC)
a custom reference — the built-in trajectory_tracking and
joint_trajectory_tracking tasks work this way (see
Control Configuration). A tracking controller
builds its reference by calling the reference builder registered for the task type:
from skelarm import register_reference_builder, register_task_type, SampledJointReference
def build_my_reference(skeleton, task):
# return a JointReference, e.g. a SampledJointReference(times, q, dq, ddq)
...
register_task_type("my_reference_task")
register_reference_builder("my_reference_task", build_my_reference)
Then any tracking controller ([controller].type = "computed_torque", …) drives it,
no controller code needed. The skelarm.interpolation and skelarm.filtering
utilities are reusable for resampling/smoothing a sampled reference (see the
theory). List reference task types with
reference_builders().
Reproducibility note
The custom [task] keys live in the scenario config, which run_scenario embeds
verbatim in the log (see Reproducible runs).
A re-run with rerun_log or an exported config reproduces the task exactly —
provided the type is registered first. Call register_task_type (and
register_controller) at import time of your module so loading a log that uses the
type always finds it; otherwise Task.from_dict raises unknown task type. Inspect
the registered types with task_types().
Related
- Defining a Controller — the logic half of a task.
- Control Configuration — the full
[task]/[controller]schema. - Joint Limits — the
enforce_limitsrun condition. - Scenario API —
Task,register_task_type,task_types.