Skip to content

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 (type, target — with optional label / color / tolerance — duration, schedule) see Control Configuration. The integration settings dt and enforce_limits are not task keys: they live in the separate [simulator] table, and the parser rejects them under [task].

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 Run condition: total simulated time / planned-motion horizon (s).
schedule Time scaling for planned trajectories (minimum_jerk, quintic, …).
tolerance / label / color Success radius and marker presentation (sub-keys of target).
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
# custom keys -> task.params
center = [0.8, 0.0]
radius = 0.4
period = 3.0

[simulator]
dt = 0.002            # the integration step lives here, not under [task]
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:

[controller]
type = "circle_tracer"
k_task = 200.0
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 Record, Replay, and Re-simulate). 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().