Control Configuration
A scenario file describes a complete controlled run in one TOML document: the
robot, its start state, the reaching task, and the controller that drives it. It
adds [task] and [controller] sections on top of the
Robot Configuration ([skeleton] / [initial]), and is
loaded by skelarm.load_scenario.
uv run python tools/reaching_simulator.py examples/reach.toml # interactive reach GUI (drag to perturb)
uv run python tools/reaching_simulator.py examples/reach.toml --save reach.sklog.npz # headless batch run + log
uv run python tools/player.py reach.sklog.npz # replay and analyze a saved run
A scenario combines four sections:
| Section | Purpose | Loader |
|---|---|---|
[skeleton] |
Robot geometry (links, base length, limits) | Skeleton.from_toml |
[initial] |
Start pose / velocity (degrees) | applied by Skeleton.from_toml |
[task] |
The goal and how the motion is shaped | Task.from_toml |
[simulator] |
How the dynamics are integrated (dt, enforce_limits) |
Simulator.from_dict |
[controller] |
The control law and its gains | build_controller |
See Trajectory Tracking Control and Reaching Control for the underlying theory.
The [task] section
| Key | Type | Default | Meaning |
|---|---|---|---|
type |
str | required | Task kind — the discriminator that decides what else the task needs. Only reaching is built in; more are planned (and you can add your own). |
target |
[x, y] or table |
required for reaching |
Endpoint goal in meters (see below). Other task types may omit it. |
duration |
float | 2.0 |
Total simulated time / planned-motion horizon, in seconds. |
schedule |
str | "minimum_jerk" |
Time scaling for planned trajectories: linear, cubic, quintic, or minimum_jerk. |
type is the one always-required key: it names the kind of task, and the kind
determines the rest (reaching requires a target). duration, schedule, and
target define the planned task-space trajectory used by the trajectory-tracking
controllers; the reaching controllers use only target and generate motion
online. The numerical-integration parameters (dt, enforce_limits) live in their
own [simulator] table, not here.
Target
target is either a plain [x, y] array (position only) or a table that carries
the position plus optional attributes:
| Target key | Type | Default | Meaning |
|---|---|---|---|
pos |
[x, y] |
required | Endpoint position in meters. |
label |
str | none | Name for the target (used to pick one among several; multi-target tasks come later). |
color |
str | "purple" |
Marker color (any Qt/SVG name or #rrggbb). |
tolerance |
float | none | Admittable tip-to-target distance (m). The reach is "reached" within it, and the marker's hollow ring is drawn at this radius. |
[task]
type = "reaching"
target = { pos = [0.55, 1.21], label = "goal", color = "purple", tolerance = 0.02 }
# array shorthand (position only) also works:
# target = [0.55, 1.21]
Task types
type selects the task kind. Beyond reaching, two trajectory-tracking types
track a reference loaded from a .sklog.npz file (e.g. one recorded with
tools/trajectory_recorder.py):
type |
Reference | Tracked by |
|---|---|---|
reaching |
A planned point-to-point reach to target. |
any controller |
multi_target_reaching |
Several candidate targets; the active one is reached (switchable live in the GUI). | any controller |
periodic_curve |
A closed task-space curve traced repeatedly, converted to joint angles by IK. | trajectory-tracking controllers |
trajectory_tracking |
The recorded tip (x, y) path, converted to joint angles by IK. |
trajectory-tracking controllers |
joint_trajectory_tracking |
The recorded per-joint q(t) series directly (no IK). |
joint-space controllers |
Each task type has a dedicated interactive simulator —
tools/reaching_simulator.py, tools/multi_target_simulator.py,
tools/periodic_curve_simulator.py, and tools/trajectory_tracking_simulator.py —
sharing the same controls (drag to apply disturbance forces, Record / Export…,
the --initial / --pose / --task / --controller overrides, and --save for a
headless batch). Their runs replay in tools/player.py, which draws the task overlay
(target, curve, or reference).
A periodic_curve task names a curve kind plus its parameters and a period (one
loop); duration sets how many loops are traced. Built-in curves:
curve |
Parameters | Shape |
|---|---|---|
circle |
center, radius, phase |
a circle |
ellipse |
center, a, b, phase |
an axis-aligned ellipse |
lemniscate |
center, a |
the Bernoulli ∞ (horizontal) |
vertical_lemniscate |
center, a, b |
an upright figure-eight |
rose |
center, a, k |
a rhodonea (k petals if odd, 2k if even) |
[task]
type = "periodic_curve"
curve = "rose"
center = [0.8, 0.0]
a = 0.4
k = 3
period = 4.0 # seconds per loop
duration = 12.0 # three loops
A multi_target_reaching task lists several candidate targets (each a [x, y] or a
{ pos, label, color, tolerance } table) and an active index (default 0). The active
target flows through the ordinary reaching pipeline; tools/multi_target_simulator.py
draws all candidates and switches the active one live when you press a number key
(1..N), retargeting a reaching controller (e.g. virtual_spring_damper) on the fly.
[task]
type = "multi_target_reaching"
active = 0
targets = [
{ pos = [1.2, 0.4], label = "A", tolerance = 0.03 },
{ pos = [0.3, 1.2], label = "B", color = "teal" },
]
The trajectory-tracking types take these extra [task] keys:
| Key | Type | Default | Meaning |
|---|---|---|---|
file |
str | required | Path to the reference .sklog.npz. |
filter |
table | none | Pre-smoothing (see below): { kind = …, cutoff_hz, order, window, polyorder }. |
interpolator |
str | "cubic_spline" |
Resampling scheme: cubic_spline, linear, or lagrange. |
The filter.kind selects the smoother and which keys it needs (zero-phase in every
case): none (off); lowpass / butterworth take a cutoff_hz (and Butterworth an
order); moving_average / savgol take a window (odd, in samples), and savgol
also a polyorder. See the theory chapter.
[task]
type = "joint_trajectory_tracking"
file = "teach.sklog.npz"
filter = { kind = "butterworth", cutoff_hz = 8.0, order = 4 } # smooth a jaggy recording
interpolator = "cubic_spline"
# duration defaults to the reference's length when omitted
If duration is omitted, it defaults to the reference trajectory's length. The
reference content is embedded in the run log, so rerun_log and exported configs
reproduce the run without the original file. Curve and DOF rules: a
joint_trajectory_tracking reference must have the same joint count as the robot.
Only these are built in. To add a goal that is not a single target point, or a new reference source, see Defining a Task.
The [simulator] section
How the dynamics are integrated, separate from the task (the desired motion). The whole
table is optional; an absent [simulator] uses the defaults.
| Key | Type | Default | Meaning |
|---|---|---|---|
dt |
float | 0.002 |
The fixed control / integration step of the simulation loop (simulate_controlled). It is also the MPC prediction step. |
enforce_limits |
bool | true |
Apply the joint limits as hard stops in the dynamics. Set false to let the limits constrain only the kinematics. |
enforce_limits is a run condition: with the default true the fixed-step loop pins
each joint at its [qmin, qmax] bound (a hard stop); with false the bounds are dropped
from the dynamics and apply only to the kinematics (posing and inverse kinematics).
Because it lives in the scenario config, the resolved value is embedded in the run's
reproduction metadata, so a saved log re-runs with the same choice. The interactive
simulators' --no-joint-limits flag overrides it off for a single run (and that resolved
value is what gets recorded). See the Joint Limits guide for the
underlying mechanics.
The [controller] section
type selects the control law; the remaining keys are its gains (any omitted key
falls back to the default below). Diagonal PD gains (kp, kd) and task-space
gains (k_task, d_task, c_joint) are isotropic scalars. To plug in a control
law of your own, see Defining a Controller.
Trajectory tracking
These build a joint reference by converting the planned task trajectory with inverse kinematics, then track it. See Trajectory Tracking Control.
type |
Controller | Keys (default) |
|---|---|---|
computed_torque |
ComputedTorque |
kp (200), kd (30) |
inverse_dynamics_pd |
InverseDynamicsFeedforwardPD |
kp (100), kd (20) |
joint_pd |
JointPD |
kp (300), kd (40) |
Reaching
These are endpoint-feedback controllers that drive the tip to target without a
preplanned trajectory. See Reaching Control.
type |
Controller | Keys (default) |
|---|---|---|
virtual_spring_damper |
VirtualSpringDamper |
k_task (150), d_task (25), c_joint (0) |
time_varying_stiffness |
TimeVaryingStiffness |
k0 (150), alpha (6), zeta1 (0.15), c_joint (0) |
online_shaping |
OnlineReferenceShaping |
k_task (150), d_task (25), c_joint (0), r (0.5), t1 (0.2), t2 (0.2) |
position_dependent_shaping |
PositionDependentShaping |
k_task (150), d_task (25), c_joint (0), a (0.01), t1 (0.2), t2 (0.2) |
adaptive_shaping |
AdaptiveReferenceShaping |
k_task (150), d_task (25), c_joint (0), epsilon (0.01), t_adapt (5.0), t1 (0.2), t2 (0.2) |
Model predictive control
type |
Controller | Keys (default) |
|---|---|---|
mpc |
JointSpaceMPC |
horizon (6), q_weight (10), dq_weight (1), tau_weight (0.001), terminal_weight (50), tau_max (none), limit_weight (0), max_iter (20) |
MPC predicts with the simulation step, so its control interval is [simulator].dt.
Re-optimizing every step is expensive at a small dt; use a larger [simulator].dt
(for example 0.05) for MPC scenarios.
Reach time vs. settling
For trajectory-tracking controllers the planned motion spans [0, duration],
so the endpoint arrives at the target at t = duration. The reaching
controllers converge asymptotically, so give duration enough margin for the
endpoint to settle.
Example
[skeleton]
base_length = 0.0
[[skeleton.link]]
length = 1.0
mass = 1.0
inertia = 0.1
com = [0.5, 0.0]
limits = [-180.0, 180.0]
[[skeleton.link]]
length = 0.8
mass = 0.8
inertia = 0.05
com = [0.4, 0.0]
limits = [-180.0, 180.0]
[initial]
q = [34.4, 57.3] # degrees
[task]
type = "reaching" # the task kind (required)
target = [0.55, 1.21] # endpoint goal (x, y) in meters (required for reaching)
duration = 2.0
schedule = "minimum_jerk"
[simulator]
dt = 0.002 # control / integration step
enforce_limits = true # joint-limit hard stop in the dynamics
[controller]
type = "computed_torque"
kp = 200.0
kd = 30.0
Swap the [controller] block to try a different law — for example a compliant,
human-like reach:
Overriding sections for comparison
tools/reaching_simulator.py can override the [initial], [task], and [controller]
sections from separate files, so one base config can be reused across a comparison
sweep without editing it. Each override file supplies the named table (e.g. a file
with just a [controller] block). With --save PATH the run is headless (no GUI)
and the log is written directly, which is convenient for a scripted sweep:
# Same robot and task, different controllers:
uv run python tools/reaching_simulator.py base.toml --controller computed_torque.toml --save ct.sklog.npz
uv run python tools/reaching_simulator.py base.toml --controller mpc.toml --save mpc.sklog.npz
# Same controller, different tasks:
uv run python tools/reaching_simulator.py base.toml --task near.toml --save near.sklog.npz
uv run python tools/reaching_simulator.py base.toml --task far.toml --save far.sklog.npz
Without --save, the same overrides configure the interactive GUI instead — e.g.
tools/reaching_simulator.py base.toml --controller pd.toml opens the reach GUI driven by the
PD controller. --initial FILE replaces the initial pose from a file's [initial]
table, and --pose 20,45 then overrides just the joint angles (degrees) — matching
the kinematics and dynamics tools. The override values are merged into the scenario,
so each saved run's log still embeds its exact (overridden) config for reproduction.
Using it from Python
from skelarm import load_scenario, run_scenario
scenario = load_scenario("examples/reach.toml")
log = run_scenario(scenario) # uses the task's duration / dt
log.save("reach.sklog.npz") # replay/analyze with tools/player.py
run_scenario runs the fixed-step control loop (like simulate_controlled) but
also embeds the scenario in the log for later reproduction. build_controller can
also be called directly with a [controller] mapping, a Task, and a Skeleton,
so controllers can be constructed without a file.
Reproducible runs
A log written by run_scenario (and by the interactive scenario simulators) is a
self-contained, re-runnable record. It embeds — in the log's [extra] metadata — the
original source config (the full [skeleton] / [initial] / [task] /
[simulator] / [controller] tables, exactly as loaded), the actual run parameters (duration /
dt / grav_vec / enforce_limits), and the skelarm / numpy / scipy versions.
enforce_limits records the resolved joint-limit choice, so a --no-joint-limits
override is reproduced on re-run even though the source config still reads true.
rerun_log reconstructs the scenario and re-simulates it:
from skelarm import rerun_log
from skelarm.recording import StateLog
log = StateLog.load("reach.sklog.npz")
again = rerun_log(log) # rebuilds the scenario and re-runs the dynamics
Reconstruction reparses the embedded source config, so identical input gives
identical state. The deterministic controllers (PD, computed torque,
inverse-dynamics feedforward, and the reaching controllers) reproduce the recorded
channels exactly on the same machine. MPC calls scipy.optimize.minimize,
which is deterministic but only bit-identical for the same scipy / BLAS build, so
an MPC re-run matches within a small numerical tolerance rather than exactly.
Export an editable config for comparison
To tweak parameters and compare, export the embedded config to an editable TOML and re-run it. Because the export is the original config verbatim, re-running it unedited reproduces the run exactly; editing a value gives a controlled variant:
from skelarm import export_scenario_toml, load_scenario, run_scenario
from skelarm.recording import StateLog
export_scenario_toml(StateLog.load("reach.sklog.npz"), "edited.toml")
# ... edit a gain / target / gravity in edited.toml ...
variant = run_scenario(load_scenario("edited.toml"))
From the command line, tools/export_config.py writes the config from a saved log:
uv run python tools/export_config.py reach.sklog.npz --output edited.toml
uv run python tools/reaching_simulator.py edited.toml # explore the edited scenario in the GUI
uv run python tools/reaching_simulator.py edited.toml --save edited.sklog.npz # or re-run it headlessly
What is not captured
A controller built programmatically (not from a config) has no embedded
config, so its run is recorded without reproduction metadata and rerun_log
(and export_scenario_toml) reject it. Re-running is available for scenarios
loaded from TOML.