Skip to content

Scenario API

skelarm.scenario

Load a full control scenario (robot + task + controller) from one TOML file.

A combined config mirrors the per-section schema used by :meth:Skeleton.from_toml: [skeleton] and [initial] describe the robot and its start state, [task] the goal (a task-space target with a movement duration), and [controller] which controller drives the reach. :func:load_scenario reads all three.

Example::

[task]
type = "reaching"  # the task kind (required)
target = [0.55, 1.21]  # endpoint goal (x, y) in meters (required for reaching)
duration = 2.0  # simulated time (s)
dt = 0.002  # control step (s)

[controller]
type = "computed_torque"
kp = 200.0
kd = 30.0

Scenario dataclass

A loaded control scenario: the robot, the task, the simulator, and the controller.

source_config keeps the original combined config (the parsed [skeleton] / [initial] / [task] / [simulator] / [controller] tables) when the scenario was loaded from a file, so a run can embed it for an exact, editable re-run; it is None for scenarios built programmatically.

Source code in src/skelarm/scenario.py
@dataclass
class Scenario:
    """A loaded control scenario: the robot, the task, the simulator, and the controller.

    ``source_config`` keeps the original combined config (the parsed ``[skeleton]`` /
    ``[initial]`` / ``[task]`` / ``[simulator]`` / ``[controller]`` tables) when the
    scenario was loaded from a file, so a run can embed it for an exact, editable re-run;
    it is ``None`` for scenarios built programmatically.
    """

    skeleton: Skeleton
    task: Task
    controller: Controller
    simulator: Simulator = field(default_factory=Simulator)
    source_config: Mapping[str, Any] | None = None

Simulator dataclass

Numerical-integration parameters for the dynamics, from the [simulator] table.

These are run/execution concerns, kept separate from the task (the desired motion).

Parameters:

Name Type Description Default
dt float

Control / integration step (seconds). Also the MPC prediction step.

0.002
enforce_limits bool

Apply the joint limits as hard stops in the dynamics (default). When False, the limits constrain only the kinematics (posing and inverse kinematics), not the simulated motion.

True
Source code in src/skelarm/scenario.py
@dataclass
class Simulator:
    """Numerical-integration parameters for the dynamics, from the ``[simulator]`` table.

    These are run/execution concerns, kept separate from the task (the desired motion).

    Parameters
    ----------
    dt : float
        Control / integration step (seconds). Also the MPC prediction step.
    enforce_limits : bool
        Apply the joint limits as hard stops in the dynamics (default). When ``False``,
        the limits constrain only the kinematics (posing and inverse kinematics), not the
        simulated motion.
    """

    dt: float = 0.002
    enforce_limits: bool = True

    @classmethod
    def from_dict(cls, section: Mapping[str, Any]) -> Simulator:
        """Build a Simulator from a ``[simulator]`` mapping (defaults when keys are absent)."""
        return cls(
            dt=float(section.get("dt", 0.002)),
            enforce_limits=bool(section.get("enforce_limits", True)),
        )

from_dict(section) classmethod

Build a Simulator from a [simulator] mapping (defaults when keys are absent).

Source code in src/skelarm/scenario.py
@classmethod
def from_dict(cls, section: Mapping[str, Any]) -> Simulator:
    """Build a Simulator from a ``[simulator]`` mapping (defaults when keys are absent)."""
    return cls(
        dt=float(section.get("dt", 0.002)),
        enforce_limits=bool(section.get("enforce_limits", True)),
    )

Task dataclass

A task and the run conditions for it.

The required field is type — the kind of task, which determines what else is needed. reaching (the only built-in) requires a target; other task types (see :func:register_task_type) carry their own data on :attr:params and may omit the target.

Parameters:

Name Type Description Default
type str

The task kind (the [task].type config key). Required.

required
target NDArray[float64] | None

The endpoint goal (x, y) in meters. Required for reaching; None for task types that do not use a single point goal.

None
label str | None

Optional name for the target, used to select it among multiple targets (multi-target tasks are defined later).

None
color str

Marker color for the target (any Qt/SVG color name or #rrggbb).

_DEFAULT_TARGET_COLOR
tolerance float | None

Optional success distance in meters: the reach succeeds when the tip is within this distance of the target, and the target marker's ring is drawn at this radius. None applies no success criterion.

None
duration float

Total simulated time / planned-motion horizon (seconds).

2.0
schedule str

Time-scaling schedule for planned-trajectory controllers.

'minimum_jerk'
params dict[str, Any]

Any extra [task] keys not recognized above, kept verbatim. A custom task type carries its own parameters here for a matching controller builder to read (e.g. task.params["radius"]).

dict()
Source code in src/skelarm/scenario.py
@dataclass
class Task:
    """A task and the run conditions for it.

    The required field is ``type`` — the kind of task, which determines what else is
    needed. ``reaching`` (the only built-in) requires a ``target``; other task types
    (see :func:`register_task_type`) carry their own data on :attr:`params` and may
    omit the target.

    Parameters
    ----------
    type : str
        The task kind (the ``[task].type`` config key). Required.
    target : NDArray[np.float64] | None
        The endpoint goal ``(x, y)`` in meters. Required for ``reaching``; ``None``
        for task types that do not use a single point goal.
    label : str | None
        Optional name for the target, used to select it among multiple targets
        (multi-target tasks are defined later).
    color : str
        Marker color for the target (any Qt/SVG color name or ``#rrggbb``).
    tolerance : float | None
        Optional success distance in meters: the reach succeeds when the tip is
        within this distance of the target, and the target marker's ring is drawn
        at this radius. ``None`` applies no success criterion.
    duration : float
        Total simulated time / planned-motion horizon (seconds).
    schedule : str
        Time-scaling schedule for planned-trajectory controllers.
    params : dict[str, Any]
        Any extra ``[task]`` keys not recognized above, kept verbatim. A custom
        task type carries its own parameters here for a matching controller builder
        to read (e.g. ``task.params["radius"]``).
    """

    type: str  # the task kind (the [task].type config key); the required discriminator
    target: NDArray[np.float64] | None = None
    label: str | None = None
    color: str = _DEFAULT_TARGET_COLOR
    tolerance: float | None = None
    duration: float = 2.0
    schedule: str = "minimum_jerk"
    params: dict[str, Any] = field(default_factory=dict)

    def require_target(self) -> NDArray[np.float64]:
        """Return the task target, raising if this task type has none.

        Use this from a controller that needs a point goal (e.g. the reaching
        controllers), so a task wired without a target fails with a clear message.

        Raises
        ------
        ValueError
            If the task has no target.
        """
        if self.target is None:
            msg = f"task type {self.type!r} has no target, but this controller requires one"
            raise ValueError(msg)
        return self.target

    @classmethod
    def from_toml(cls, file_path: str | Path) -> Task:
        """Build a Task from the ``[task]`` table of a TOML file."""
        with Path(file_path).open("rb") as f:
            data = tomllib.load(f)
        if "task" not in data:
            msg = f"no [task] section in {file_path}"
            raise ValueError(msg)
        return cls.from_dict(data["task"])

    @classmethod
    def from_dict(cls, section: Mapping[str, Any]) -> Task:
        """Build a Task from a ``[task]`` mapping.

        ``type`` is required; it selects the task kind. The ``reaching`` type also
        requires a ``target`` — either a ``[x, y]`` array (position only) or a table
        with a ``pos`` and optional ``label`` / ``color`` / ``tolerance``. Other task
        types may omit the target. Any keys beyond the recognized ones are kept on
        :attr:`params` for custom task types.

        Raises
        ------
        ValueError
            If ``type`` is missing or unknown, or a ``reaching`` task has no/ill-shaped
            ``target``.
        """
        if "type" not in section:
            msg = "[task] requires a 'type' (e.g. type = \"reaching\")"
            raise ValueError(msg)
        task_type = str(section["type"])
        if task_type not in _TASK_TYPES:
            msg = f"unknown task type {task_type!r}; choose from {sorted(_TASK_TYPES)} (or register_task_type)"
            raise ValueError(msg)
        moved = [key for key in _MOVED_TO_SIMULATOR if key in section]
        if moved:
            msg = f"[task] keys {moved} moved to the [simulator] table; set them under [simulator] instead"
            raise ValueError(msg)

        pos: NDArray[np.float64] | None
        if "target" in section:
            pos, label, color, tolerance = _parse_target(section["target"])
        elif task_type == _REACHING_TYPE:
            msg = "[task] of type 'reaching' requires a 'target' endpoint"
            raise ValueError(msg)
        else:
            pos, label, color, tolerance = None, None, _DEFAULT_TARGET_COLOR, None

        params = {key: value for key, value in section.items() if key not in _KNOWN_TASK_KEYS}
        return cls(
            type=task_type,
            target=pos,
            label=label,
            color=color,
            tolerance=tolerance,
            duration=float(section.get("duration", 2.0)),
            schedule=str(section.get("schedule", "minimum_jerk")),
            params=params,
        )

from_dict(section) classmethod

Build a Task from a [task] mapping.

type is required; it selects the task kind. The reaching type also requires a target — either a [x, y] array (position only) or a table with a pos and optional label / color / tolerance. Other task types may omit the target. Any keys beyond the recognized ones are kept on :attr:params for custom task types.

Raises:

Type Description
ValueError

If type is missing or unknown, or a reaching task has no/ill-shaped target.

Source code in src/skelarm/scenario.py
@classmethod
def from_dict(cls, section: Mapping[str, Any]) -> Task:
    """Build a Task from a ``[task]`` mapping.

    ``type`` is required; it selects the task kind. The ``reaching`` type also
    requires a ``target`` — either a ``[x, y]`` array (position only) or a table
    with a ``pos`` and optional ``label`` / ``color`` / ``tolerance``. Other task
    types may omit the target. Any keys beyond the recognized ones are kept on
    :attr:`params` for custom task types.

    Raises
    ------
    ValueError
        If ``type`` is missing or unknown, or a ``reaching`` task has no/ill-shaped
        ``target``.
    """
    if "type" not in section:
        msg = "[task] requires a 'type' (e.g. type = \"reaching\")"
        raise ValueError(msg)
    task_type = str(section["type"])
    if task_type not in _TASK_TYPES:
        msg = f"unknown task type {task_type!r}; choose from {sorted(_TASK_TYPES)} (or register_task_type)"
        raise ValueError(msg)
    moved = [key for key in _MOVED_TO_SIMULATOR if key in section]
    if moved:
        msg = f"[task] keys {moved} moved to the [simulator] table; set them under [simulator] instead"
        raise ValueError(msg)

    pos: NDArray[np.float64] | None
    if "target" in section:
        pos, label, color, tolerance = _parse_target(section["target"])
    elif task_type == _REACHING_TYPE:
        msg = "[task] of type 'reaching' requires a 'target' endpoint"
        raise ValueError(msg)
    else:
        pos, label, color, tolerance = None, None, _DEFAULT_TARGET_COLOR, None

    params = {key: value for key, value in section.items() if key not in _KNOWN_TASK_KEYS}
    return cls(
        type=task_type,
        target=pos,
        label=label,
        color=color,
        tolerance=tolerance,
        duration=float(section.get("duration", 2.0)),
        schedule=str(section.get("schedule", "minimum_jerk")),
        params=params,
    )

from_toml(file_path) classmethod

Build a Task from the [task] table of a TOML file.

Source code in src/skelarm/scenario.py
@classmethod
def from_toml(cls, file_path: str | Path) -> Task:
    """Build a Task from the ``[task]`` table of a TOML file."""
    with Path(file_path).open("rb") as f:
        data = tomllib.load(f)
    if "task" not in data:
        msg = f"no [task] section in {file_path}"
        raise ValueError(msg)
    return cls.from_dict(data["task"])

require_target()

Return the task target, raising if this task type has none.

Use this from a controller that needs a point goal (e.g. the reaching controllers), so a task wired without a target fails with a clear message.

Raises:

Type Description
ValueError

If the task has no target.

Source code in src/skelarm/scenario.py
def require_target(self) -> NDArray[np.float64]:
    """Return the task target, raising if this task type has none.

    Use this from a controller that needs a point goal (e.g. the reaching
    controllers), so a task wired without a target fails with a clear message.

    Raises
    ------
    ValueError
        If the task has no target.
    """
    if self.target is None:
        msg = f"task type {self.type!r} has no target, but this controller requires one"
        raise ValueError(msg)
    return self.target

active_target_index(task)

The configured active-target index of a multi-target task (default 0).

Source code in src/skelarm/scenario.py
def active_target_index(task: Task) -> int:
    """The configured active-target index of a multi-target task (default 0)."""
    return int(task.params.get("active", 0))

apply_active_target(task)

Set a multi-target task's first-class target (and marker attrs) to its active candidate.

Lets the active goal flow through the ordinary reaching pipeline (controller build, marker drawing); the GUI switches it live by reassigning task.target / the controller's target.

Raises:

Type Description
ValueError

If the active index is out of range.

Source code in src/skelarm/scenario.py
def apply_active_target(task: Task) -> None:
    """Set a multi-target task's first-class ``target`` (and marker attrs) to its active candidate.

    Lets the active goal flow through the ordinary reaching pipeline (controller build,
    marker drawing); the GUI switches it live by reassigning ``task.target`` / the
    controller's target.

    Raises
    ------
    ValueError
        If the active index is out of range.
    """
    specs = multi_target_specs(task)
    index = active_target_index(task)
    if not 0 <= index < len(specs):
        msg = f"active target index {index} is out of range for {len(specs)} targets"
        raise ValueError(msg)
    task.target, task.label, task.color, task.tolerance = specs[index]

build_controller(source, *, skeleton, task, dt=0.002)

Construct the controller described by a [controller] config.

Parameters:

Name Type Description Default
source str | Path | Mapping[str, Any]

A TOML file path (its [controller] table is read) or the table itself.

required
skeleton Skeleton

The robot, posed at its start state; used to build joint references.

required
task Task

The reaching task supplying the target and run conditions.

required
dt float

The control / integration step (from [simulator]). It is injected into the params mapping handed to the builder as params["dt"], so a controller that needs the step (e.g. MPC, whose prediction step must match it) can read it.

0.002

Returns:

Type Description
Controller

The configured controller.

Raises:

Type Description
ValueError

If the config has no type or an unknown controller type.

Source code in src/skelarm/scenario.py
def build_controller(
    source: str | Path | Mapping[str, Any],
    *,
    skeleton: Skeleton,
    task: Task,
    dt: float = 0.002,
) -> Controller:
    """Construct the controller described by a ``[controller]`` config.

    Parameters
    ----------
    source : str | Path | Mapping[str, Any]
        A TOML file path (its ``[controller]`` table is read) or the table itself.
    skeleton : Skeleton
        The robot, posed at its start state; used to build joint references.
    task : Task
        The reaching task supplying the target and run conditions.
    dt : float, optional
        The control / integration step (from ``[simulator]``). It is injected into the
        ``params`` mapping handed to the builder as ``params["dt"]``, so a controller that
        needs the step (e.g. MPC, whose prediction step must match it) can read it.

    Returns
    -------
    Controller
        The configured controller.

    Raises
    ------
    ValueError
        If the config has no ``type`` or an unknown controller ``type``.
    """
    config = source if isinstance(source, Mapping) else _read_controller_section(source)
    controller_type = config.get("type")
    if controller_type not in _BUILDERS:
        msg = f"unknown controller type {controller_type!r}; choose from {list(controller_types())}"
        raise ValueError(msg)
    params = {key: value for key, value in config.items() if key != "type"}
    params["dt"] = dt  # the control step, available to controller builders that need it
    return _BUILDERS[controller_type](params, skeleton, task)

controller_types()

Return the registered [controller].type values, sorted.

Source code in src/skelarm/scenario.py
def controller_types() -> tuple[str, ...]:
    """Return the registered ``[controller].type`` values, sorted."""
    return tuple(sorted(_BUILDERS))

export_scenario_toml(log, path)

Write the log's embedded scenario config to an editable TOML file.

The output is a standard combined config ([skeleton] / [initial] / [task] / [controller]) that :func:load_scenario reads back. Re-running the unedited file reproduces the original run exactly for the deterministic controllers, and individual values can be edited for comparison studies.

Parameters:

Name Type Description Default
log StateLog

A log produced by :func:run_scenario (carrying the source config).

required
path str | Path

Destination .toml path.

required

Raises:

Type Description
ValueError

If the log carries no embedded scenario config.

Source code in src/skelarm/scenario.py
def export_scenario_toml(log: StateLog, path: str | Path) -> None:
    """Write the log's embedded scenario config to an editable TOML file.

    The output is a standard combined config (``[skeleton]`` / ``[initial]`` /
    ``[task]`` / ``[controller]``) that :func:`load_scenario` reads back. Re-running
    the unedited file reproduces the original run exactly for the deterministic
    controllers, and individual values can be edited for comparison studies.

    Parameters
    ----------
    log : StateLog
        A log produced by :func:`run_scenario` (carrying the source config).
    path : str | Path
        Destination ``.toml`` path.

    Raises
    ------
    ValueError
        If the log carries no embedded scenario config.
    """
    config = log.extra.get("source_config")
    if not config:
        msg = "log has no embedded scenario config to export; it was not produced by run_scenario"
        raise ValueError(msg)
    Path(path).write_text(dump_toml(config).strip() + "\n", encoding="utf-8")

load_reference_log(path)

Load a .sklog.npz reference trajectory (e.g. from tools/trajectory_recorder.py).

Source code in src/skelarm/scenario.py
def load_reference_log(path: str | Path) -> StateLog:
    """Load a ``.sklog.npz`` reference trajectory (e.g. from ``tools/trajectory_recorder.py``)."""
    file = Path(path)
    if not file.exists():
        msg = f"reference file not found: {file}"
        raise FileNotFoundError(msg)
    return StateLog.load(file)

load_scenario(file_path)

Load a robot, task, and controller from one combined TOML file.

Source code in src/skelarm/scenario.py
def load_scenario(file_path: str | Path) -> Scenario:
    """Load a robot, task, and controller from one combined TOML file."""
    with Path(file_path).open("rb") as f:
        data = tomllib.load(f)
    return scenario_from_config(data)

multi_target_specs(task)

Parse a multi-target task's targets list into (pos, label, color, tolerance) tuples.

Raises:

Type Description
ValueError

If the task carries no targets list.

Source code in src/skelarm/scenario.py
def multi_target_specs(task: Task) -> list[tuple[NDArray[np.float64], str | None, str, float | None]]:
    """Parse a multi-target task's ``targets`` list into ``(pos, label, color, tolerance)`` tuples.

    Raises
    ------
    ValueError
        If the task carries no ``targets`` list.
    """
    raw = task.params.get("targets")
    if not raw:
        msg = "[task] of type 'multi_target_reaching' requires a non-empty 'targets' list"
        raise ValueError(msg)
    return [_parse_target(entry) for entry in raw]

reference_builders()

Return the task types that provide a joint reference, sorted.

Source code in src/skelarm/scenario.py
def reference_builders() -> tuple[str, ...]:
    """Return the task types that provide a joint reference, sorted."""
    return tuple(sorted(_REFERENCE_BUILDERS))

register_controller(name, builder)

Register a controller builder so [controller].type = name builds it.

The builder receives the [controller] table (without type) as a mapping, the posed Skeleton, and the Task, and returns a :class:~skelarm.Controller. Re-registering an existing name replaces it. Custom controllers are also usable directly (construct the instance and pass it to :func:run_scenario / :func:~skelarm.simulate_controlled); registration is only needed for the config-driven path.

Parameters:

Name Type Description Default
name str

The [controller].type value to bind.

required
builder ControllerBuilder

A callable (params, skeleton, task) -> Controller.

required
Source code in src/skelarm/scenario.py
def register_controller(name: str, builder: ControllerBuilder) -> None:
    """Register a controller builder so ``[controller].type = name`` builds it.

    The builder receives the ``[controller]`` table (without ``type``) as a mapping,
    the posed ``Skeleton``, and the ``Task``, and returns a :class:`~skelarm.Controller`.
    Re-registering an existing name replaces it. Custom controllers are also usable
    directly (construct the instance and pass it to :func:`run_scenario` /
    :func:`~skelarm.simulate_controlled`); registration is only needed for the
    config-driven path.

    Parameters
    ----------
    name : str
        The ``[controller].type`` value to bind.
    builder : ControllerBuilder
        A callable ``(params, skeleton, task) -> Controller``.
    """
    _BUILDERS[name] = builder

register_reference_builder(task_type, builder)

Register a joint-reference builder for a [task].type.

A tracking controller (computed torque, joint PD, inverse-dynamics PD, MPC) builds its reference by calling the builder registered for the scenario's task type. Use this to add a new trajectory-style task that drives the existing controllers.

Parameters:

Name Type Description Default
task_type str

The [task].type value to bind.

required
builder ReferenceBuilder

A callable (skeleton, task) -> JointReference.

required
Source code in src/skelarm/scenario.py
def register_reference_builder(task_type: str, builder: ReferenceBuilder) -> None:
    """Register a joint-reference builder for a ``[task].type``.

    A tracking controller (computed torque, joint PD, inverse-dynamics PD, MPC) builds
    its reference by calling the builder registered for the scenario's task type. Use
    this to add a new trajectory-style task that drives the existing controllers.

    Parameters
    ----------
    task_type : str
        The ``[task].type`` value to bind.
    builder : ReferenceBuilder
        A callable ``(skeleton, task) -> JointReference``.
    """
    _REFERENCE_BUILDERS[task_type] = builder

register_task_type(name)

Allow name as a [task].type.

Task types are a label that a controller interprets; a custom task carries its parameters on :attr:Task.params (any extra [task] keys). Register the type so :meth:Task.from_dict accepts it, then read task.params from a matching controller built via :func:register_controller.

Parameters:

Name Type Description Default
name str

The task-type string to allow (idempotent).

required
Source code in src/skelarm/scenario.py
def register_task_type(name: str) -> None:
    """Allow ``name`` as a ``[task].type``.

    Task types are a label that a controller interprets; a custom task carries its
    parameters on :attr:`Task.params` (any extra ``[task]`` keys). Register the type
    so :meth:`Task.from_dict` accepts it, then read ``task.params`` from a matching
    controller built via :func:`register_controller`.

    Parameters
    ----------
    name : str
        The task-type string to allow (idempotent).
    """
    _TASK_TYPES.add(name)

rerun_log(log)

Reconstruct and re-simulate a scenario recorded by :func:run_scenario.

Rebuilds the scenario by reparsing the embedded source config (identical input gives identical state) and re-runs with the recorded run parameters, so deterministic controllers reproduce the original channels exactly (MPC, which calls :func:scipy.optimize.minimize, reproduces within a small numerical tolerance on the same platform).

Raises:

Type Description
ValueError

If the log carries no reproduction metadata.

Source code in src/skelarm/scenario.py
def rerun_log(log: StateLog) -> StateLog:
    """Reconstruct and re-simulate a scenario recorded by :func:`run_scenario`.

    Rebuilds the scenario by reparsing the embedded source config (identical input
    gives identical state) and re-runs with the recorded run parameters, so
    deterministic controllers reproduce the original channels exactly (MPC, which
    calls :func:`scipy.optimize.minimize`, reproduces within a small numerical
    tolerance on the same platform).

    Raises
    ------
    ValueError
        If the log carries no reproduction metadata.
    """
    scenario, run = scenario_from_log(log)
    grav_vec = np.asarray(run["grav_vec"], dtype=np.float64)
    # Older logs may predate the recorded enforce_limits; fall back to the simulator's value.
    enforce_limits = run.get("enforce_limits", scenario.simulator.enforce_limits)
    return run_scenario(
        scenario, duration=run["duration"], dt=run["dt"], grav_vec=grav_vec, enforce_limits=enforce_limits
    )

run_scenario(scenario, *, duration=None, dt=None, grav_vec=None, enforce_limits=None)

Simulate a scenario and embed its full config for later reproduction.

Runs :func:~skelarm.control.simulate_controlled and, when the scenario was loaded from a config, stores the task, controller, initial state, and run parameters in the log's [extra] table so :func:rerun_log can reconstruct and re-simulate it.

Parameters:

Name Type Description Default
scenario Scenario

The robot, task, and controller to run.

required
duration float | None

Simulated duration (seconds); defaults to scenario.task.duration.

None
dt float | None

Control / integration step; defaults to scenario.simulator.dt.

None
grav_vec NDArray[float64] | None

Gravity vector; defaults to zero (planar motion).

None
enforce_limits bool | None

Apply the joint limits as hard stops in the dynamics. None (the default) uses the scenario's simulator.enforce_limits; an explicit value overrides it (e.g. a --no-joint-limits CLI flag). The resolved value is recorded in the run metadata so the override is reproduced on re-run.

None

Returns:

Type Description
StateLog

The recorded run, with reproduction metadata when available.

Source code in src/skelarm/scenario.py
def run_scenario(
    scenario: Scenario,
    *,
    duration: float | None = None,
    dt: float | None = None,
    grav_vec: NDArray[np.float64] | None = None,
    enforce_limits: bool | None = None,
) -> StateLog:
    """Simulate a scenario and embed its full config for later reproduction.

    Runs :func:`~skelarm.control.simulate_controlled` and, when the scenario was
    loaded from a config, stores the task, controller, initial state, and run
    parameters in the log's ``[extra]`` table so :func:`rerun_log` can reconstruct
    and re-simulate it.

    Parameters
    ----------
    scenario : Scenario
        The robot, task, and controller to run.
    duration : float | None, optional
        Simulated duration (seconds); defaults to ``scenario.task.duration``.
    dt : float | None, optional
        Control / integration step; defaults to ``scenario.simulator.dt``.
    grav_vec : NDArray[np.float64] | None, optional
        Gravity vector; defaults to zero (planar motion).
    enforce_limits : bool | None, optional
        Apply the joint limits as hard stops in the dynamics. ``None`` (the default)
        uses the scenario's ``simulator.enforce_limits``; an explicit value overrides it
        (e.g. a ``--no-joint-limits`` CLI flag). The resolved value is recorded in
        the run metadata so the override is reproduced on re-run.

    Returns
    -------
    StateLog
        The recorded run, with reproduction metadata when available.
    """
    run_duration = scenario.task.duration if duration is None else float(duration)
    run_dt = scenario.simulator.dt if dt is None else float(dt)
    grav = np.zeros(_TASK_DIM, dtype=np.float64) if grav_vec is None else np.asarray(grav_vec, dtype=np.float64)
    run_enforce_limits = scenario.simulator.enforce_limits if enforce_limits is None else bool(enforce_limits)
    extra = _reproduction_metadata(
        scenario, duration=run_duration, dt=run_dt, grav_vec=grav, enforce_limits=run_enforce_limits
    )
    return simulate_controlled(
        scenario.skeleton,
        scenario.controller,
        duration=run_duration,
        dt=run_dt,
        grav_vec=grav,
        enforce_limits=run_enforce_limits,
        extra=extra,
    )

scenario_from_config(data)

Build a Scenario from a parsed combined config and keep the config for re-runs.

Reuses the real parsers (:meth:Skeleton.from_config, :meth:Task.from_dict, :func:build_controller), so rebuilding from an identical mapping reproduces the robot, initial pose, task, and controller exactly.

Raises:

Type Description
ValueError

If the config has no [task] or [controller] table.

Source code in src/skelarm/scenario.py
def scenario_from_config(data: Mapping[str, Any]) -> Scenario:
    """Build a Scenario from a parsed combined config and keep the config for re-runs.

    Reuses the real parsers (:meth:`Skeleton.from_config`, :meth:`Task.from_dict`,
    :func:`build_controller`), so rebuilding from an identical mapping reproduces the
    robot, initial pose, task, and controller exactly.

    Raises
    ------
    ValueError
        If the config has no ``[task]`` or ``[controller]`` table.
    """
    if "task" not in data:
        msg = "no [task] section in scenario config"
        raise ValueError(msg)
    if "controller" not in data:
        msg = "no [controller] section in scenario config"
        raise ValueError(msg)
    resolved = _inline_reference_samples(data)
    skeleton = Skeleton.from_config(resolved)
    task = Task.from_dict(resolved["task"])
    if task.type == _MULTI_TARGET_TYPE:
        apply_active_target(task)  # set the first-class target to the active candidate
    simulator = Simulator.from_dict(resolved.get("simulator", {}))
    controller = build_controller(resolved["controller"], skeleton=skeleton, task=task, dt=simulator.dt)
    return Scenario(
        skeleton=skeleton, task=task, controller=controller, simulator=simulator, source_config=dict(resolved)
    )

scenario_from_log(log)

Reconstruct the scenario and run parameters embedded in a log by :func:run_scenario.

Returns:

Type Description
tuple[Scenario, Mapping[str, Any]]

The rebuilt scenario (skeleton posed at the recorded initial state) and the run parameters (duration / dt / grav_vec).

Raises:

Type Description
ValueError

If the log carries no reproduction metadata (was not produced by :func:run_scenario).

Source code in src/skelarm/scenario.py
def scenario_from_log(log: StateLog) -> tuple[Scenario, Mapping[str, Any]]:
    """Reconstruct the scenario and run parameters embedded in a log by :func:`run_scenario`.

    Returns
    -------
    tuple[Scenario, Mapping[str, Any]]
        The rebuilt scenario (skeleton posed at the recorded initial state) and the
        ``run`` parameters (``duration`` / ``dt`` / ``grav_vec``).

    Raises
    ------
    ValueError
        If the log carries no reproduction metadata (was not produced by
        :func:`run_scenario`).
    """
    config = log.extra.get("source_config")
    if not config:
        msg = "log has no reproduction metadata; it was not produced by run_scenario"
        raise ValueError(msg)
    scenario = scenario_from_config(config)
    run = log.extra.get(
        "run",
        {
            "duration": scenario.task.duration,
            "dt": scenario.simulator.dt,
            "grav_vec": [0.0, 0.0],
            "enforce_limits": scenario.simulator.enforce_limits,
        },
    )
    return scenario, run

task_types()

Return the registered [task].type values, sorted.

Source code in src/skelarm/scenario.py
def task_types() -> tuple[str, ...]:
    """Return the registered ``[task].type`` values, sorted."""
    return tuple(sorted(_TASK_TYPES))