Defining a Controller
A controller is the logic that drives the arm: each control step it reads the
robot state and returns the joint torque τ. It is the active half of a
scenario — the task says where to go, the controller decides
how. This guide covers the controller interface, using a built-in, and writing
and registering a new controller of your own.
The built-in control laws and their theory are documented in Trajectory Tracking Control and Reaching Control; their config keys are in Control Configuration.
The controller interface
Subclass Controller (API). Only control is required:
| Method | Required | Purpose |
|---|---|---|
control(t, skeleton) -> tau |
yes | Return the joint torque (shape (num_joints,)) for the current time and state. |
reset(skeleton) |
no | Initialize internal state at movement onset (t = 0). |
update(t, skeleton, dt) |
no | Advance internal state once per fixed control step. |
log_channels() -> dict |
no | Expose internal signals to record each step (e.g. q_ref, error). |
A controller is also callable — controller(t, skeleton) forwards to control, so a
stateless controller works directly as a simulate_robot
torque callback. Stateful controllers use reset / update together with the
fixed-step loop (simulate_controlled / run_scenario).
Gravity is ignored
The arm is planar and horizontal, so do not add gravity terms. See the physics notes in the Inverse Dynamics reference.
Option 1 — use a built-in controller
Pick a type in the [controller] table and set its gains; no code needed. The
available types are listed by controller_types() and documented in
Control Configuration.
Option 2 — write a new controller
A task-space example
This controller pulls the tip toward the task target with a virtual spring, mapping
the task-space force to joint torque through the endpoint Jacobian, plus joint
damping for stability (the same Jᵀ·force pattern the reaching controllers use):
import numpy as np
from skelarm import Controller, compute_jacobian
class TaskSpaceSpring(Controller):
"""Tip spring toward a fixed target: τ = Jᵀ k (p* − p) − d q̇."""
def __init__(self, target, *, k_task=200.0, d_joint=20.0):
self.target = np.asarray(target, dtype=float)
self.k_task = k_task
self.d_joint = d_joint
def control(self, t, skeleton):
tip = skeleton.links[-1]
position = np.array([tip.xe, tip.ye])
force = self.k_task * (self.target - position) # task-space restoring force
return compute_jacobian(skeleton).T @ force - self.d_joint * skeleton.dq
def log_channels(self):
return {} # optionally record internal signals here
Drive it directly — no registration needed for one-off experiments:
from skelarm import Skeleton, simulate_controlled
skeleton = Skeleton.from_toml("examples/four_dof_robot.toml")
log = simulate_controlled(skeleton, TaskSpaceSpring([1.0, 0.5]), duration=2.0, dt=0.002)
Tracking a reference
For trajectory-tracking laws, subclass TrackingController (it stores a
JointReference and PD gains, and logs q_ref / error) and read the reference in
control:
import numpy as np
from skelarm import TrackingController
class WeightedPD(TrackingController):
def control(self, t, skeleton):
q_r, dq_r, _ = self.reference.sample(t) # the joint reference at time t
error = q_r - skeleton.q
self._store(q_r, error) # makes log_channels emit q_ref / error
return self.kp * error + self.kd * (dq_r - skeleton.dq)
Build a joint reference from a task-space path with ik_joint_reference (or supply
your own object implementing JointReference.sample(t) -> (q_r, dq_r, ddq_r)); see
the Control API.
Make it config-driven and reproducible
To select your controller from a [controller].type, register a builder —
(params, skeleton, task) -> Controller — with register_controller. params is
the [controller] table without type, plus params["dt"] — the control step from
[simulator] — for controllers that need it (e.g. MPC, whose prediction step must match
it); use task for the target and run conditions:
from skelarm import register_controller
def build_task_space_spring(params, skeleton, task):
return TaskSpaceSpring(task.require_target(), # raises clearly if the task has no target
k_task=params.get("k_task", 200.0),
d_joint=params.get("d_joint", 20.0))
register_controller("task_space_spring", build_task_space_spring)
from skelarm import load_scenario, run_scenario
log = run_scenario(load_scenario("reach.toml")) # builds and runs your controller
log.save("reach.sklog.npz")
run_scenario embeds the [controller] config (gains and all) in the log, so
rerun_log and an exported config reproduce the run exactly (see
Reproducible runs). Register the
builder at your module's import time so re-running a saved log always finds the
type — otherwise build_controller raises unknown controller type. Re-registering
a name replaces it; list the registered types with controller_types().
Related
- Defining a Task — the goal a controller drives toward.
- Control Configuration — built-in
[controller]types and keys. - Trajectory Tracking Control and Reaching Control — the theory behind the built-ins.
- Control API and Scenario API —
Controller,TrackingController,register_controller,controller_types.