Skip to content

Skeleton API

skelarm.skeleton

Defines the Link and Skeleton classes for the skelarm robot arm simulator.

Represents a single link of the robot arm.

Source code in src/skelarm/skeleton.py
class Link:
    """Represents a single link of the robot arm."""

    def __init__(self, properties: LinkProp | dict[str, Any]) -> None:
        """Initialize a Link object from LinkProp or a dictionary.

        Parameters
        ----------
        properties : LinkProp | dict[str, Any]
            Either a LinkProp object or a dictionary containing link properties
            ('length', 'm', 'i', 'rgx', 'rgy', 'qmin', 'qmax').
        """
        if isinstance(properties, LinkProp):
            self.prop = properties
        elif isinstance(properties, dict):
            # Copy first so the caller's dict is not mutated, then accept 'l' as
            # an alias for 'length'.
            props = dict(properties)
            if "l" in props:
                props["length"] = props.pop("l")
            self.prop = LinkProp(**props)
        else:
            error_msg = "Properties must be a LinkProp object or a dictionary."
            raise TypeError(error_msg)

        # State variables, initialized to zeros or defaults
        self.q: float = 0.0  # Joint angle
        self.dq: float = 0.0  # Joint angular velocity
        self.ddq: float = 0.0  # Joint angular acceleration
        self.q_absolute: float = 0.0  # Absolute angle of the link (used in RNE)

        # Joint-origin (link start) position, plus tip (end-effector) velocity
        # and acceleration in the base frame. The tip position is xe/ye below.
        self.x: float = 0.0  # joint-origin x
        self.y: float = 0.0  # joint-origin y
        self.vx: float = 0.0  # tip velocity x
        self.vy: float = 0.0  # tip velocity y
        self.ax: float = 0.0  # tip acceleration x
        self.ay: float = 0.0  # tip acceleration y

        # Center of mass position and velocity (global)
        self.xg: float = 0.0
        self.yg: float = 0.0
        self.agx: float = 0.0
        self.agy: float = 0.0

        # Endpoint Jacobian column and centripetal/Coriolis acceleration basis
        # for this joint (filled by the differential-kinematics helpers).
        self.jx: float = 0.0  # x-component of this joint's Jacobian column
        self.jy: float = 0.0  # y-component of this joint's Jacobian column
        self.hx: float = 0.0  # x-component of the centripetal/Coriolis basis
        self.hy: float = 0.0  # y-component of the centripetal/Coriolis basis

        # Joint forces/torques
        self.fx: float = 0.0  # Force applied to link x
        self.fy: float = 0.0  # Force applied to link y
        self.tau: float = 0.0  # Torque at joint

        # External forces/torques
        self.fex: float = 0.0  # External force x
        self.fey: float = 0.0  # External force y
        self.rex: float = 0.0  # External force x application point in the link frame
        self.rey: float = 0.0  # External force y application point in the link frame

        # End-effector position (global)
        self.xe: float = 0.0
        self.ye: float = 0.0

        # --- Attributes for Inverse Dynamics (RNE) ---
        self.w: float = 0.0  # Angular velocity of link frame
        self.dw: float = 0.0  # Angular acceleration of link frame

        # Linear velocity/acceleration of link origin (joint)
        self.v: NDArray[np.float64] = cast("NDArray[np.float64]", np.zeros(2))
        self.dv: NDArray[np.float64] = cast("NDArray[np.float64]", np.zeros(2))

        # Linear velocity/acceleration of center of mass
        self.vc: NDArray[np.float64] = cast("NDArray[np.float64]", np.zeros(2))
        self.dvc: NDArray[np.float64] = cast("NDArray[np.float64]", np.zeros(2))

        # Forces and moments
        self.f: NDArray[np.float64] = cast("NDArray[np.float64]", np.zeros(2))  # Force from parent link
        self.n: float = 0.0  # Moment exerted by parent link on current link

__init__(properties)

Initialize a Link object from LinkProp or a dictionary.

Parameters:

Name Type Description Default
properties LinkProp | dict[str, Any]

Either a LinkProp object or a dictionary containing link properties ('length', 'm', 'i', 'rgx', 'rgy', 'qmin', 'qmax').

required
Source code in src/skelarm/skeleton.py
def __init__(self, properties: LinkProp | dict[str, Any]) -> None:
    """Initialize a Link object from LinkProp or a dictionary.

    Parameters
    ----------
    properties : LinkProp | dict[str, Any]
        Either a LinkProp object or a dictionary containing link properties
        ('length', 'm', 'i', 'rgx', 'rgy', 'qmin', 'qmax').
    """
    if isinstance(properties, LinkProp):
        self.prop = properties
    elif isinstance(properties, dict):
        # Copy first so the caller's dict is not mutated, then accept 'l' as
        # an alias for 'length'.
        props = dict(properties)
        if "l" in props:
            props["length"] = props.pop("l")
        self.prop = LinkProp(**props)
    else:
        error_msg = "Properties must be a LinkProp object or a dictionary."
        raise TypeError(error_msg)

    # State variables, initialized to zeros or defaults
    self.q: float = 0.0  # Joint angle
    self.dq: float = 0.0  # Joint angular velocity
    self.ddq: float = 0.0  # Joint angular acceleration
    self.q_absolute: float = 0.0  # Absolute angle of the link (used in RNE)

    # Joint-origin (link start) position, plus tip (end-effector) velocity
    # and acceleration in the base frame. The tip position is xe/ye below.
    self.x: float = 0.0  # joint-origin x
    self.y: float = 0.0  # joint-origin y
    self.vx: float = 0.0  # tip velocity x
    self.vy: float = 0.0  # tip velocity y
    self.ax: float = 0.0  # tip acceleration x
    self.ay: float = 0.0  # tip acceleration y

    # Center of mass position and velocity (global)
    self.xg: float = 0.0
    self.yg: float = 0.0
    self.agx: float = 0.0
    self.agy: float = 0.0

    # Endpoint Jacobian column and centripetal/Coriolis acceleration basis
    # for this joint (filled by the differential-kinematics helpers).
    self.jx: float = 0.0  # x-component of this joint's Jacobian column
    self.jy: float = 0.0  # y-component of this joint's Jacobian column
    self.hx: float = 0.0  # x-component of the centripetal/Coriolis basis
    self.hy: float = 0.0  # y-component of the centripetal/Coriolis basis

    # Joint forces/torques
    self.fx: float = 0.0  # Force applied to link x
    self.fy: float = 0.0  # Force applied to link y
    self.tau: float = 0.0  # Torque at joint

    # External forces/torques
    self.fex: float = 0.0  # External force x
    self.fey: float = 0.0  # External force y
    self.rex: float = 0.0  # External force x application point in the link frame
    self.rey: float = 0.0  # External force y application point in the link frame

    # End-effector position (global)
    self.xe: float = 0.0
    self.ye: float = 0.0

    # --- Attributes for Inverse Dynamics (RNE) ---
    self.w: float = 0.0  # Angular velocity of link frame
    self.dw: float = 0.0  # Angular acceleration of link frame

    # Linear velocity/acceleration of link origin (joint)
    self.v: NDArray[np.float64] = cast("NDArray[np.float64]", np.zeros(2))
    self.dv: NDArray[np.float64] = cast("NDArray[np.float64]", np.zeros(2))

    # Linear velocity/acceleration of center of mass
    self.vc: NDArray[np.float64] = cast("NDArray[np.float64]", np.zeros(2))
    self.dvc: NDArray[np.float64] = cast("NDArray[np.float64]", np.zeros(2))

    # Forces and moments
    self.f: NDArray[np.float64] = cast("NDArray[np.float64]", np.zeros(2))  # Force from parent link
    self.n: float = 0.0  # Moment exerted by parent link on current link

LinkProp dataclass

Properties of a single robot arm link.

Source code in src/skelarm/skeleton.py
@dataclass
class LinkProp:
    """Properties of a single robot arm link."""

    length: float
    m: float
    i: float
    rgx: float
    rgy: float
    qmin: float  # Radians
    qmax: float  # Radians

Skeleton

Represents the entire robot arm (skeleton).

Source code in src/skelarm/skeleton.py
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
class Skeleton:
    """Represents the entire robot arm (skeleton)."""

    def __init__(
        self,
        link_props: Sequence[LinkProp | dict[str, Any]],
        base_length: float = 0.0,
    ) -> None:
        """Initialize the Skeleton with a list of movable-link properties.

        Following the reference notes, the arm is built around a fixed *base*
        (zeroth) link of length ``base_length``. It is stored as ``links[0]`` and
        carries the first joint at ``(base_length, 0)``; the actuated links given
        in ``link_props`` follow as ``links[1:]``. A "2-link arm" therefore holds
        three links in total: the base plus two movable links.

        Forward kinematics is computed once at the end of construction, so the
        derived link states (positions, COM, ...) start out consistent with the
        initial pose.

        Parameters
        ----------
        link_props : Sequence[LinkProp | dict[str, Any]]
            A list of LinkProp objects or dictionaries, one for each *movable*
            link.
        base_length : float, optional
            Length of the fixed base link, i.e. the offset from the origin to the
            first joint. Defaults to 0.0 (first joint at the origin).
        """
        base_prop = LinkProp(length=base_length, m=0.0, i=0.0, rgx=0.0, rgy=0.0, qmin=0.0, qmax=0.0)
        self.links: list[Link] = [Link(base_prop), *(Link(prop) for prop in link_props)]
        # Total number of links including the fixed base (matches the reference
        # ``num``); the actuated degrees of freedom are the movable links only.
        self.num_links: int = len(self.links)
        self.num_joints: int = self.num_links - 1
        # Make the derived link states (positions, COM, ...) consistent with the
        # initial pose right away, so a fresh skeleton can be drawn or queried
        # without an explicit forward-kinematics call.
        compute_forward_kinematics(self)

    @property
    def base_length(self) -> float:
        """Length of the fixed base (zeroth) link."""
        return self.links[0].prop.length

    @classmethod
    def from_toml(cls, file_path: str | Path) -> Skeleton:
        """Create a Skeleton from a TOML configuration file.

        Parameters
        ----------
        file_path : str | Path
            Path to the TOML file.

        Returns
        -------
        Skeleton
            A new Skeleton instance whose link positions are already consistent
            with the initial pose.

        Raises
        ------
        ValueError
            If ``[initial].q`` or ``[initial].dq`` has a length other than the
            number of actuated joints.

        Notes
        -----
        The canonical layout nests skeleton keys under a ``[skeleton]`` table,
        with links given as ``[[skeleton.link]]`` and an optional
        ``base_length``. This lets a robot live alongside ``[initial]`` (and
        future ``[task]`` / ``[controller]``) sections in a single combined file
        while still being loadable on its own. Legacy flat configs (top-level
        ``base_length`` and ``[[link]]``) remain supported as a fallback.

        The initial state is a *run condition*, configured in a top-level
        ``[initial]`` section with ``q`` (degrees) and optional ``dq``
        (degrees/second), one value per joint. ``[initial].q`` takes precedence
        over the per-link ``q0`` keys (a soft-deprecated fallback that defaults
        each joint to zero when neither is given).

        Each link's joint limits come from its ``limits = [min, max]`` (or
        ``qmin`` / ``qmax``) keys, in degrees. A link that omits them defaults to
        ``[-180, 180]`` degrees. Limits are enforced: setting joint angles
        clamps them into this range (with a warning), so an unspecified joint is
        capped at one full revolution rather than left unbounded.
        """
        path = Path(file_path)
        with path.open("rb") as f:
            data = tomllib.load(f)
        return cls.from_config(data)

    @classmethod
    def from_config(cls, data: Mapping[str, Any]) -> Skeleton:
        """Build a Skeleton from an already-parsed combined config mapping.

        This is the dict-based core of :meth:`from_toml`: it reads the same
        ``[skeleton]`` / ``[initial]`` schema (degrees) and applies the initial
        pose. Rebuilding from the identical mapping reproduces the robot and pose
        exactly, which underpins reproducible re-runs from an exported config.

        Parameters
        ----------
        data : Mapping[str, Any]
            A parsed combined config with an optional ``[skeleton]`` table (links
            as ``[[skeleton.link]]``, degrees) and an optional ``[initial]`` table.

        Returns
        -------
        Skeleton
            A new Skeleton posed at the configured initial state.
        """
        # The canonical layout nests skeleton keys under a ``[skeleton]`` table so
        # robot, task, and controller configs can coexist in one combined file. Fall
        # back to top-level keys for legacy (flat) configs.
        section = data.get("skeleton", data)

        # Optional fixed base link length (offset from the origin to joint 1).
        base_length = float(section.get("base_length", 0.0))

        link_props = []
        initial_angles = []
        for link_data in section.get("link", []):
            # Extract properties from TOML data
            length = link_data["length"]
            mass = link_data["mass"]
            inertia = link_data["inertia"]

            # Allow 'com' as [x, y] or 'rgx'/'rgy' keys
            if "com" in link_data:
                rgx, rgy = link_data["com"]
            else:
                rgx = link_data.get("rgx", 0.0)
                rgy = link_data.get("rgy", 0.0)

            # Allow 'limits' as [min, max] or 'qmin'/'qmax' keys
            if "limits" in link_data:
                qmin_deg, qmax_deg = link_data["limits"]
            else:
                qmin_deg = link_data.get("qmin", -180.0)
                qmax_deg = link_data.get("qmax", 180.0)

            # Convert limits from degrees (config) to radians (internal)
            qmin = np.deg2rad(qmin_deg)
            qmax = np.deg2rad(qmax_deg)

            # Optional initial joint angle, in degrees like the limits.
            initial_angles.append(np.deg2rad(link_data.get("q0", 0.0)))

            link_props.append(
                LinkProp(
                    length=length,
                    m=mass,
                    i=inertia,
                    rgx=rgx,
                    rgy=rgy,
                    qmin=qmin,
                    qmax=qmax,
                )
            )

        skeleton = cls(link_props, base_length=base_length)

        # Initial state is a *run condition*, kept in its own top-level
        # ``[initial]`` section so the same robot can be compared under different
        # postures. ``q`` (degrees) and ``dq`` (degrees/second) each take one
        # value per joint and override the per-link ``q0`` defaults.
        _apply_initial(skeleton, data, default_q=np.array(initial_angles, dtype=np.float64))
        return skeleton

    def to_dict(self) -> dict[str, Any]:
        """Serialize the robot geometry to a plain, JSON/TOML-friendly dictionary.

        Captures the fixed ``base_length`` and one entry per actuated link with the
        native :class:`LinkProp` fields (lengths in meters, ``qmin`` / ``qmax`` in
        radians), so :meth:`from_dict` reproduces the robot exactly. The joint state
        (``q`` / ``dq``) is a separate run condition and is not included.

        Returns
        -------
        dict[str, Any]
            ``{"base_length": float, "links": [<link prop dict>, ...]}``.
        """
        return {
            "base_length": self.base_length,
            "links": [asdict(link.prop) for link in self.links[1:]],
        }

    @classmethod
    def from_dict(cls, data: dict[str, Any]) -> Skeleton:
        """Reconstruct a Skeleton from a :meth:`to_dict` mapping.

        Parameters
        ----------
        data : dict[str, Any]
            A mapping with an optional ``base_length`` and a ``links`` list of
            :class:`LinkProp` field dictionaries.

        Returns
        -------
        Skeleton
            A new Skeleton whose link positions match the zero pose.
        """
        base_length = float(data.get("base_length", 0.0))
        link_props = [LinkProp(**link) for link in data["links"]]
        return cls(link_props, base_length=base_length)

    def clone(self) -> Skeleton:
        """Return a new, independent Skeleton with the same geometry and state.

        The clone duplicates the link geometry (its ``LinkProp`` objects are
        separate) and copies the current joint state (``q``, ``dq``, ``ddq``,
        ``tau``); its derived link states are refreshed to match.

        Returns
        -------
        Skeleton
            A deep copy of this skeleton.
        """
        duplicate = Skeleton.from_dict(self.to_dict())
        self.copy_state_to(duplicate)
        return duplicate

    def copy_state_to(self, target: Skeleton) -> None:
        """Copy this skeleton's joint state onto an existing skeleton, in place.

        Copies the joint angles, velocities, accelerations, and torques (``q``,
        ``dq``, ``ddq``, ``tau``) onto ``target`` and refreshes its derived link
        states. ``target``'s geometry is left unchanged. Values are written
        directly, so the state is reproduced exactly without limit clamping.

        Parameters
        ----------
        target : Skeleton
            The skeleton to overwrite; it must have the same number of joints.

        Raises
        ------
        ValueError
            If ``target`` has a different number of movable joints.
        """
        if target.num_joints != self.num_joints:
            error_msg = f"Expected a target with {self.num_joints} joint(s), but got {target.num_joints}"
            raise ValueError(error_msg)
        for source, destination in zip(self.links[1:], target.links[1:], strict=True):
            destination.q = source.q
            destination.dq = source.dq
            destination.ddq = source.ddq
            destination.tau = source.tau
        compute_forward_kinematics(target)

    def apply_initial_toml(self, file_path: str | Path) -> None:
        """Apply the ``[initial]`` table from a TOML file to this skeleton.

        Reads ``q`` (degrees) and optional ``dq`` (degrees/second) — one value per
        joint — from the file's ``[initial]`` section and sets the joint state. This
        lets a separate posture file be applied to an already-loaded robot. Joints
        are left unchanged when ``q`` (or ``dq``) is absent.

        Parameters
        ----------
        file_path : str | Path
            Path to a TOML file containing an ``[initial]`` table.

        Raises
        ------
        ValueError
            If ``[initial].q`` or ``[initial].dq`` has a length other than the
            number of actuated joints.
        """
        path = Path(file_path)
        with path.open("rb") as f:
            data = tomllib.load(f)
        _apply_initial(self, data, default_q=self.q)

    @property
    def q(self) -> NDArray[np.float64]:
        """Return current joint angles (one per movable link)."""
        return np.array([link.q for link in self.links[1:]], dtype=np.float64)

    @q.setter
    def q(self, q_values: NDArray[np.float64]) -> None:
        """Set joint angles (clamped to each joint's limits) and refresh the derived link states."""
        if len(q_values) != self.num_joints:
            error_msg = f"Expected {self.num_joints} joint angles, but got {len(q_values)}"
            raise ValueError(error_msg)
        q_values = self._clamp_to_joint_limits(q_values)
        for link, value in zip(self.links[1:], q_values, strict=True):
            link.q = value
        compute_forward_kinematics(self)

    def _clamp_to_joint_limits(self, q_values: NDArray[np.float64]) -> NDArray[np.float64]:
        """Clamp joint angles to each movable link's ``[qmin, qmax]``, warning when any are out of range.

        Joint limits are enforced on the kinematics-facing angle setters (this method
        backs both the ``q`` setter and :meth:`set_state`). The recursive dynamics
        solvers write ``link.q`` directly and are intentionally left unconstrained.

        Parameters
        ----------
        q_values : NDArray[np.float64]
            Requested joint angles, one per movable link (radians).

        Returns
        -------
        NDArray[np.float64]
            The angles clipped into each joint's limit range.
        """
        lower = np.array([link.prop.qmin for link in self.links[1:]], dtype=np.float64)
        upper = np.array([link.prop.qmax for link in self.links[1:]], dtype=np.float64)
        clamped = np.clip(np.asarray(q_values, dtype=np.float64), lower, upper)

        out_of_range = np.nonzero(clamped != q_values)[0]
        if out_of_range.size > 0:
            details = ", ".join(
                f"joint {i + 1}: {np.rad2deg(q_values[i]):.4g} deg -> {np.rad2deg(clamped[i]):.4g} deg"
                for i in out_of_range
            )
            warnings.warn(f"Joint angle(s) outside limits were clamped ({details}).", stacklevel=3)
        return clamped

    @property
    def dq(self) -> NDArray[np.float64]:
        """Return current joint angular velocities (one per movable link)."""
        return np.array([link.dq for link in self.links[1:]], dtype=np.float64)

    @dq.setter
    def dq(self, dq_values: NDArray[np.float64]) -> None:
        """Set joint angular velocities and refresh the derived link states."""
        if len(dq_values) != self.num_joints:
            error_msg = f"Expected {self.num_joints} joint angular velocities, but got {len(dq_values)}"
            raise ValueError(error_msg)
        for link, value in zip(self.links[1:], dq_values, strict=True):
            link.dq = value
        compute_forward_kinematics(self)

    @property
    def ddq(self) -> NDArray[np.float64]:
        """Return current joint angular accelerations (one per movable link)."""
        return np.array([link.ddq for link in self.links[1:]], dtype=np.float64)

    @ddq.setter
    def ddq(self, ddq_values: NDArray[np.float64]) -> None:
        """Set joint angular accelerations and refresh the derived link states."""
        if len(ddq_values) != self.num_joints:
            error_msg = f"Expected {self.num_joints} joint angular accelerations, but got {len(ddq_values)}"
            raise ValueError(error_msg)
        for link, value in zip(self.links[1:], ddq_values, strict=True):
            link.ddq = value
        compute_forward_kinematics(self)

    def set_state(
        self,
        q: NDArray[np.float64] | None = None,
        dq: NDArray[np.float64] | None = None,
        ddq: NDArray[np.float64] | None = None,
    ) -> None:
        """Set joint angles, velocities, and accelerations in one call.

        Unlike assigning ``q``, ``dq``, and ``ddq`` separately (each of which
        re-runs forward kinematics), this writes all provided values first and
        refreshes the derived link states with a single forward-kinematics
        pass. Arguments left as ``None`` keep their current values. ``q`` is
        clamped to each joint's ``[qmin, qmax]`` limits (with a warning), like
        the ``q`` setter.

        Parameters
        ----------
        q : NDArray[np.float64] | None, optional
            Joint angles, one per movable link.
        dq : NDArray[np.float64] | None, optional
            Joint angular velocities, one per movable link.
        ddq : NDArray[np.float64] | None, optional
            Joint angular accelerations, one per movable link.

        Raises
        ------
        ValueError
            If any provided array does not hold one value per movable link.
            The skeleton is left unmodified in that case.
        """
        # Validate everything before writing anything, so a bad argument
        # cannot leave the skeleton partially updated.
        for name, values in (("q", q), ("dq", dq), ("ddq", ddq)):
            if values is not None and len(values) != self.num_joints:
                error_msg = f"Expected {self.num_joints} values for {name}, but got {len(values)}"
                raise ValueError(error_msg)

        if q is not None:
            for link, value in zip(self.links[1:], self._clamp_to_joint_limits(q), strict=True):
                link.q = value
        if dq is not None:
            for link, value in zip(self.links[1:], dq, strict=True):
                link.dq = value
        if ddq is not None:
            for link, value in zip(self.links[1:], ddq, strict=True):
                link.ddq = value
        compute_forward_kinematics(self)

    @property
    def tau(self) -> NDArray[np.float64]:
        """Return current joint torques (one per movable link)."""
        return np.array([link.tau for link in self.links[1:]], dtype=np.float64)

    @tau.setter
    def tau(self, tau_values: NDArray[np.float64]) -> None:
        """Set joint torques."""
        if len(tau_values) != self.num_joints:
            error_msg = f"Expected {self.num_joints} joint torques, but got {len(tau_values)}"
            raise ValueError(error_msg)
        for link, value in zip(self.links[1:], tau_values, strict=True):
            link.tau = value

base_length property

Length of the fixed base (zeroth) link.

ddq property writable

Return current joint angular accelerations (one per movable link).

dq property writable

Return current joint angular velocities (one per movable link).

q property writable

Return current joint angles (one per movable link).

tau property writable

Return current joint torques (one per movable link).

__init__(link_props, base_length=0.0)

Initialize the Skeleton with a list of movable-link properties.

Following the reference notes, the arm is built around a fixed base (zeroth) link of length base_length. It is stored as links[0] and carries the first joint at (base_length, 0); the actuated links given in link_props follow as links[1:]. A "2-link arm" therefore holds three links in total: the base plus two movable links.

Forward kinematics is computed once at the end of construction, so the derived link states (positions, COM, ...) start out consistent with the initial pose.

Parameters:

Name Type Description Default
link_props Sequence[LinkProp | dict[str, Any]]

A list of LinkProp objects or dictionaries, one for each movable link.

required
base_length float

Length of the fixed base link, i.e. the offset from the origin to the first joint. Defaults to 0.0 (first joint at the origin).

0.0
Source code in src/skelarm/skeleton.py
def __init__(
    self,
    link_props: Sequence[LinkProp | dict[str, Any]],
    base_length: float = 0.0,
) -> None:
    """Initialize the Skeleton with a list of movable-link properties.

    Following the reference notes, the arm is built around a fixed *base*
    (zeroth) link of length ``base_length``. It is stored as ``links[0]`` and
    carries the first joint at ``(base_length, 0)``; the actuated links given
    in ``link_props`` follow as ``links[1:]``. A "2-link arm" therefore holds
    three links in total: the base plus two movable links.

    Forward kinematics is computed once at the end of construction, so the
    derived link states (positions, COM, ...) start out consistent with the
    initial pose.

    Parameters
    ----------
    link_props : Sequence[LinkProp | dict[str, Any]]
        A list of LinkProp objects or dictionaries, one for each *movable*
        link.
    base_length : float, optional
        Length of the fixed base link, i.e. the offset from the origin to the
        first joint. Defaults to 0.0 (first joint at the origin).
    """
    base_prop = LinkProp(length=base_length, m=0.0, i=0.0, rgx=0.0, rgy=0.0, qmin=0.0, qmax=0.0)
    self.links: list[Link] = [Link(base_prop), *(Link(prop) for prop in link_props)]
    # Total number of links including the fixed base (matches the reference
    # ``num``); the actuated degrees of freedom are the movable links only.
    self.num_links: int = len(self.links)
    self.num_joints: int = self.num_links - 1
    # Make the derived link states (positions, COM, ...) consistent with the
    # initial pose right away, so a fresh skeleton can be drawn or queried
    # without an explicit forward-kinematics call.
    compute_forward_kinematics(self)

apply_initial_toml(file_path)

Apply the [initial] table from a TOML file to this skeleton.

Reads q (degrees) and optional dq (degrees/second) — one value per joint — from the file's [initial] section and sets the joint state. This lets a separate posture file be applied to an already-loaded robot. Joints are left unchanged when q (or dq) is absent.

Parameters:

Name Type Description Default
file_path str | Path

Path to a TOML file containing an [initial] table.

required

Raises:

Type Description
ValueError

If [initial].q or [initial].dq has a length other than the number of actuated joints.

Source code in src/skelarm/skeleton.py
def apply_initial_toml(self, file_path: str | Path) -> None:
    """Apply the ``[initial]`` table from a TOML file to this skeleton.

    Reads ``q`` (degrees) and optional ``dq`` (degrees/second) — one value per
    joint — from the file's ``[initial]`` section and sets the joint state. This
    lets a separate posture file be applied to an already-loaded robot. Joints
    are left unchanged when ``q`` (or ``dq``) is absent.

    Parameters
    ----------
    file_path : str | Path
        Path to a TOML file containing an ``[initial]`` table.

    Raises
    ------
    ValueError
        If ``[initial].q`` or ``[initial].dq`` has a length other than the
        number of actuated joints.
    """
    path = Path(file_path)
    with path.open("rb") as f:
        data = tomllib.load(f)
    _apply_initial(self, data, default_q=self.q)

clone()

Return a new, independent Skeleton with the same geometry and state.

The clone duplicates the link geometry (its LinkProp objects are separate) and copies the current joint state (q, dq, ddq, tau); its derived link states are refreshed to match.

Returns:

Type Description
Skeleton

A deep copy of this skeleton.

Source code in src/skelarm/skeleton.py
def clone(self) -> Skeleton:
    """Return a new, independent Skeleton with the same geometry and state.

    The clone duplicates the link geometry (its ``LinkProp`` objects are
    separate) and copies the current joint state (``q``, ``dq``, ``ddq``,
    ``tau``); its derived link states are refreshed to match.

    Returns
    -------
    Skeleton
        A deep copy of this skeleton.
    """
    duplicate = Skeleton.from_dict(self.to_dict())
    self.copy_state_to(duplicate)
    return duplicate

copy_state_to(target)

Copy this skeleton's joint state onto an existing skeleton, in place.

Copies the joint angles, velocities, accelerations, and torques (q, dq, ddq, tau) onto target and refreshes its derived link states. target's geometry is left unchanged. Values are written directly, so the state is reproduced exactly without limit clamping.

Parameters:

Name Type Description Default
target Skeleton

The skeleton to overwrite; it must have the same number of joints.

required

Raises:

Type Description
ValueError

If target has a different number of movable joints.

Source code in src/skelarm/skeleton.py
def copy_state_to(self, target: Skeleton) -> None:
    """Copy this skeleton's joint state onto an existing skeleton, in place.

    Copies the joint angles, velocities, accelerations, and torques (``q``,
    ``dq``, ``ddq``, ``tau``) onto ``target`` and refreshes its derived link
    states. ``target``'s geometry is left unchanged. Values are written
    directly, so the state is reproduced exactly without limit clamping.

    Parameters
    ----------
    target : Skeleton
        The skeleton to overwrite; it must have the same number of joints.

    Raises
    ------
    ValueError
        If ``target`` has a different number of movable joints.
    """
    if target.num_joints != self.num_joints:
        error_msg = f"Expected a target with {self.num_joints} joint(s), but got {target.num_joints}"
        raise ValueError(error_msg)
    for source, destination in zip(self.links[1:], target.links[1:], strict=True):
        destination.q = source.q
        destination.dq = source.dq
        destination.ddq = source.ddq
        destination.tau = source.tau
    compute_forward_kinematics(target)

from_config(data) classmethod

Build a Skeleton from an already-parsed combined config mapping.

This is the dict-based core of :meth:from_toml: it reads the same [skeleton] / [initial] schema (degrees) and applies the initial pose. Rebuilding from the identical mapping reproduces the robot and pose exactly, which underpins reproducible re-runs from an exported config.

Parameters:

Name Type Description Default
data Mapping[str, Any]

A parsed combined config with an optional [skeleton] table (links as [[skeleton.link]], degrees) and an optional [initial] table.

required

Returns:

Type Description
Skeleton

A new Skeleton posed at the configured initial state.

Source code in src/skelarm/skeleton.py
@classmethod
def from_config(cls, data: Mapping[str, Any]) -> Skeleton:
    """Build a Skeleton from an already-parsed combined config mapping.

    This is the dict-based core of :meth:`from_toml`: it reads the same
    ``[skeleton]`` / ``[initial]`` schema (degrees) and applies the initial
    pose. Rebuilding from the identical mapping reproduces the robot and pose
    exactly, which underpins reproducible re-runs from an exported config.

    Parameters
    ----------
    data : Mapping[str, Any]
        A parsed combined config with an optional ``[skeleton]`` table (links
        as ``[[skeleton.link]]``, degrees) and an optional ``[initial]`` table.

    Returns
    -------
    Skeleton
        A new Skeleton posed at the configured initial state.
    """
    # The canonical layout nests skeleton keys under a ``[skeleton]`` table so
    # robot, task, and controller configs can coexist in one combined file. Fall
    # back to top-level keys for legacy (flat) configs.
    section = data.get("skeleton", data)

    # Optional fixed base link length (offset from the origin to joint 1).
    base_length = float(section.get("base_length", 0.0))

    link_props = []
    initial_angles = []
    for link_data in section.get("link", []):
        # Extract properties from TOML data
        length = link_data["length"]
        mass = link_data["mass"]
        inertia = link_data["inertia"]

        # Allow 'com' as [x, y] or 'rgx'/'rgy' keys
        if "com" in link_data:
            rgx, rgy = link_data["com"]
        else:
            rgx = link_data.get("rgx", 0.0)
            rgy = link_data.get("rgy", 0.0)

        # Allow 'limits' as [min, max] or 'qmin'/'qmax' keys
        if "limits" in link_data:
            qmin_deg, qmax_deg = link_data["limits"]
        else:
            qmin_deg = link_data.get("qmin", -180.0)
            qmax_deg = link_data.get("qmax", 180.0)

        # Convert limits from degrees (config) to radians (internal)
        qmin = np.deg2rad(qmin_deg)
        qmax = np.deg2rad(qmax_deg)

        # Optional initial joint angle, in degrees like the limits.
        initial_angles.append(np.deg2rad(link_data.get("q0", 0.0)))

        link_props.append(
            LinkProp(
                length=length,
                m=mass,
                i=inertia,
                rgx=rgx,
                rgy=rgy,
                qmin=qmin,
                qmax=qmax,
            )
        )

    skeleton = cls(link_props, base_length=base_length)

    # Initial state is a *run condition*, kept in its own top-level
    # ``[initial]`` section so the same robot can be compared under different
    # postures. ``q`` (degrees) and ``dq`` (degrees/second) each take one
    # value per joint and override the per-link ``q0`` defaults.
    _apply_initial(skeleton, data, default_q=np.array(initial_angles, dtype=np.float64))
    return skeleton

from_dict(data) classmethod

Reconstruct a Skeleton from a :meth:to_dict mapping.

Parameters:

Name Type Description Default
data dict[str, Any]

A mapping with an optional base_length and a links list of :class:LinkProp field dictionaries.

required

Returns:

Type Description
Skeleton

A new Skeleton whose link positions match the zero pose.

Source code in src/skelarm/skeleton.py
@classmethod
def from_dict(cls, data: dict[str, Any]) -> Skeleton:
    """Reconstruct a Skeleton from a :meth:`to_dict` mapping.

    Parameters
    ----------
    data : dict[str, Any]
        A mapping with an optional ``base_length`` and a ``links`` list of
        :class:`LinkProp` field dictionaries.

    Returns
    -------
    Skeleton
        A new Skeleton whose link positions match the zero pose.
    """
    base_length = float(data.get("base_length", 0.0))
    link_props = [LinkProp(**link) for link in data["links"]]
    return cls(link_props, base_length=base_length)

from_toml(file_path) classmethod

Create a Skeleton from a TOML configuration file.

Parameters:

Name Type Description Default
file_path str | Path

Path to the TOML file.

required

Returns:

Type Description
Skeleton

A new Skeleton instance whose link positions are already consistent with the initial pose.

Raises:

Type Description
ValueError

If [initial].q or [initial].dq has a length other than the number of actuated joints.

Notes

The canonical layout nests skeleton keys under a [skeleton] table, with links given as [[skeleton.link]] and an optional base_length. This lets a robot live alongside [initial] (and future [task] / [controller]) sections in a single combined file while still being loadable on its own. Legacy flat configs (top-level base_length and [[link]]) remain supported as a fallback.

The initial state is a run condition, configured in a top-level [initial] section with q (degrees) and optional dq (degrees/second), one value per joint. [initial].q takes precedence over the per-link q0 keys (a soft-deprecated fallback that defaults each joint to zero when neither is given).

Each link's joint limits come from its limits = [min, max] (or qmin / qmax) keys, in degrees. A link that omits them defaults to [-180, 180] degrees. Limits are enforced: setting joint angles clamps them into this range (with a warning), so an unspecified joint is capped at one full revolution rather than left unbounded.

Source code in src/skelarm/skeleton.py
@classmethod
def from_toml(cls, file_path: str | Path) -> Skeleton:
    """Create a Skeleton from a TOML configuration file.

    Parameters
    ----------
    file_path : str | Path
        Path to the TOML file.

    Returns
    -------
    Skeleton
        A new Skeleton instance whose link positions are already consistent
        with the initial pose.

    Raises
    ------
    ValueError
        If ``[initial].q`` or ``[initial].dq`` has a length other than the
        number of actuated joints.

    Notes
    -----
    The canonical layout nests skeleton keys under a ``[skeleton]`` table,
    with links given as ``[[skeleton.link]]`` and an optional
    ``base_length``. This lets a robot live alongside ``[initial]`` (and
    future ``[task]`` / ``[controller]``) sections in a single combined file
    while still being loadable on its own. Legacy flat configs (top-level
    ``base_length`` and ``[[link]]``) remain supported as a fallback.

    The initial state is a *run condition*, configured in a top-level
    ``[initial]`` section with ``q`` (degrees) and optional ``dq``
    (degrees/second), one value per joint. ``[initial].q`` takes precedence
    over the per-link ``q0`` keys (a soft-deprecated fallback that defaults
    each joint to zero when neither is given).

    Each link's joint limits come from its ``limits = [min, max]`` (or
    ``qmin`` / ``qmax``) keys, in degrees. A link that omits them defaults to
    ``[-180, 180]`` degrees. Limits are enforced: setting joint angles
    clamps them into this range (with a warning), so an unspecified joint is
    capped at one full revolution rather than left unbounded.
    """
    path = Path(file_path)
    with path.open("rb") as f:
        data = tomllib.load(f)
    return cls.from_config(data)

set_state(q=None, dq=None, ddq=None)

Set joint angles, velocities, and accelerations in one call.

Unlike assigning q, dq, and ddq separately (each of which re-runs forward kinematics), this writes all provided values first and refreshes the derived link states with a single forward-kinematics pass. Arguments left as None keep their current values. q is clamped to each joint's [qmin, qmax] limits (with a warning), like the q setter.

Parameters:

Name Type Description Default
q NDArray[float64] | None

Joint angles, one per movable link.

None
dq NDArray[float64] | None

Joint angular velocities, one per movable link.

None
ddq NDArray[float64] | None

Joint angular accelerations, one per movable link.

None

Raises:

Type Description
ValueError

If any provided array does not hold one value per movable link. The skeleton is left unmodified in that case.

Source code in src/skelarm/skeleton.py
def set_state(
    self,
    q: NDArray[np.float64] | None = None,
    dq: NDArray[np.float64] | None = None,
    ddq: NDArray[np.float64] | None = None,
) -> None:
    """Set joint angles, velocities, and accelerations in one call.

    Unlike assigning ``q``, ``dq``, and ``ddq`` separately (each of which
    re-runs forward kinematics), this writes all provided values first and
    refreshes the derived link states with a single forward-kinematics
    pass. Arguments left as ``None`` keep their current values. ``q`` is
    clamped to each joint's ``[qmin, qmax]`` limits (with a warning), like
    the ``q`` setter.

    Parameters
    ----------
    q : NDArray[np.float64] | None, optional
        Joint angles, one per movable link.
    dq : NDArray[np.float64] | None, optional
        Joint angular velocities, one per movable link.
    ddq : NDArray[np.float64] | None, optional
        Joint angular accelerations, one per movable link.

    Raises
    ------
    ValueError
        If any provided array does not hold one value per movable link.
        The skeleton is left unmodified in that case.
    """
    # Validate everything before writing anything, so a bad argument
    # cannot leave the skeleton partially updated.
    for name, values in (("q", q), ("dq", dq), ("ddq", ddq)):
        if values is not None and len(values) != self.num_joints:
            error_msg = f"Expected {self.num_joints} values for {name}, but got {len(values)}"
            raise ValueError(error_msg)

    if q is not None:
        for link, value in zip(self.links[1:], self._clamp_to_joint_limits(q), strict=True):
            link.q = value
    if dq is not None:
        for link, value in zip(self.links[1:], dq, strict=True):
            link.dq = value
    if ddq is not None:
        for link, value in zip(self.links[1:], ddq, strict=True):
            link.ddq = value
    compute_forward_kinematics(self)

to_dict()

Serialize the robot geometry to a plain, JSON/TOML-friendly dictionary.

Captures the fixed base_length and one entry per actuated link with the native :class:LinkProp fields (lengths in meters, qmin / qmax in radians), so :meth:from_dict reproduces the robot exactly. The joint state (q / dq) is a separate run condition and is not included.

Returns:

Type Description
dict[str, Any]

{"base_length": float, "links": [<link prop dict>, ...]}.

Source code in src/skelarm/skeleton.py
def to_dict(self) -> dict[str, Any]:
    """Serialize the robot geometry to a plain, JSON/TOML-friendly dictionary.

    Captures the fixed ``base_length`` and one entry per actuated link with the
    native :class:`LinkProp` fields (lengths in meters, ``qmin`` / ``qmax`` in
    radians), so :meth:`from_dict` reproduces the robot exactly. The joint state
    (``q`` / ``dq``) is a separate run condition and is not included.

    Returns
    -------
    dict[str, Any]
        ``{"base_length": float, "links": [<link prop dict>, ...]}``.
    """
    return {
        "base_length": self.base_length,
        "links": [asdict(link.prop) for link in self.links[1:]],
    }