Skip to content

Plants

shinro.plants

Robot plant models for simulation and control.

Provides concrete plant implementations that wrap robot kinematics and optionally attach a MuJoCo physics engine for mesh-accurate simulation.

Available plants: ArmRobot — 6-DOF serial-link arm with FK, Jacobian, IK HolonomicMobileRobot — N-wheel holonomic base with omni-wheel kinematics InvertedPendulum — 2D inverted pendulum with analytical dynamics CartPole — 4D cart-pole with coupled dynamics DoublePendulum — 4D planar double pendulum with analytical dynamics Quadrotor — 12D quadrotor (placeholder)


ArmRobot

Bases: Plant

6-DOF robotic arm plant with forward kinematics, Jacobian, and IK.

Models a serial-link manipulator with configurable joint offsets and rotation axes. Supports two modes:

  1. Standalone — uses simplified FK/IK for quick testing.
  2. Physics engine — attaches a PhysicsEngine for mesh-accurate Jacobian IK via MuJoCo.

The arm operates in Cartesian space: step() takes a 6D velocity twist \([dx, dy, dz, d\text{roll}, d\text{pitch}, d\text{yaw}]\), integrates to a target pose, and uses inverse kinematics to compute joint angles. The controller never touches joint space.

Forward kinematics uses homogeneous transforms (4x4) chained per joint. The Jacobian is computed via the geometric method (cross product of joint axes with position vectors). Inverse kinematics uses damped pseudoinverse with step clamping and joint limit enforcement.

Parameters:

Name Type Description Default
num_dof int

Number of degrees of freedom.

required
dt float

Time step in seconds.

required
joint_limits

Array of shape (num_dof, 2) with [min, max] per joint.

required
joint_offsets

Array of shape (num_dof, 3) with link offset vectors.

required
rot_axes list[str]

List of rotation axes per joint ("x", "y", or "z").

required
joint_names list[str] | None

Optional list of joint name strings.

None
ee_body_name str | None

Optional name of the end-effector body in the physics engine.

None
backend ArrayBackend | None

Array backend. Defaults to NumpyBackend.

None
Source code in src/shinro/plants/armrobot.py
def __init__(
    self,
    num_dof: int,
    dt: float,
    joint_limits,
    joint_offsets,
    rot_axes: list[str],
    joint_names: list[str] | None = None,
    ee_body_name: str | None = None,
    backend: ArrayBackend | None = None,
):
    self.bk = backend or NumpyBackend()
    self.num_dof = num_dof
    self.dt = dt
    self.state = self.bk.zeros(6)
    self.joint_offsets = joint_offsets
    self.joint_limits = joint_limits
    self.axes = rot_axes
    self._last_joints = self.bk.zeros(num_dof)
    self._engine = None
    self._ee_body_name = ee_body_name
    self._joint_names = joint_names if joint_names is not None else [f"joint_{i}" for i in range(num_dof)]

physics_engine

physics_engine(engine: PhysicsEngine | None)

Attach a physics engine.

When attached, the backend is inherited from the engine, the end-effector body name is auto-detected, and the initial state is read from the engine.

When detached, the standalone FK is used for state initialization.

Parameters:

Name Type Description Default
engine PhysicsEngine | None

PhysicsEngine instance or None to detach.

required
Source code in src/shinro/plants/armrobot.py
def physics_engine(self, engine: PhysicsEngine | None):
    """Attach a physics engine.

    When attached, the backend is inherited from the engine, the
    end-effector body name is auto-detected, and the initial state
    is read from the engine.

    When detached, the standalone FK is used for state initialization.

    Args:
        engine: PhysicsEngine instance or None to detach.
    """
    self._engine = engine
    if engine is not None:
        self.bk = engine.backend
        if self._ee_body_name is None:
            self._ee_body_name = self._find_ee_body_name(engine)
        assert self._engine is not None
        self._engine.forward()
        ee = self._get_ee_pos()
        self.state = self.bk.hstack([ee, self._get_ee_euler()])
    else:
        T_home, _, _ = self.forward_kinematics(self.bk.zeros(self.num_dof))
        pos = T_home[:3, 3]
        self.state = self.bk.hstack([pos, self.bk.zeros(3)])

get_model

get_model()

Get the discrete-time state-space model.

Returns:

Type Description

Tuple of (A, B) where A = I_6 and B = dt * I_6.

Source code in src/shinro/plants/armrobot.py
def get_model(self):
    """Get the discrete-time state-space model.

    Returns:
        Tuple of (A, B) where A = I_6 and B = dt * I_6.
    """
    A = self.bk.eye(6)
    B = self.dt * self.bk.eye(6)
    return A, B

step

step(u)

Execute one control step.

When a physics engine is attached: - Integrates the Cartesian velocity twist to get a target EE pose (position + orientation, ZYX Euler rates — same math as standalone). - Runs engine_ik() to compute joint angles. - Sends joint angles to the engine via set_joint_ctrl.

When standalone: - Integrates the state directly. - Runs the standalone inverse_kinematics(). - Clips joint angles to limits.

Parameters:

Name Type Description Default
u

Control input (6,) — [dx, dy, dz, droll, dpitch, dyaw].

required

Returns:

Type Description

Joint angle vector (num_dof,).

Source code in src/shinro/plants/armrobot.py
def step(self, u):
    """Execute one control step.

    When a physics engine is attached:
    - Integrates the Cartesian velocity twist to get a target EE pose
      (position + orientation, ZYX Euler rates — same math as standalone).
    - Runs ``engine_ik()`` to compute joint angles.
    - Sends joint angles to the engine via ``set_joint_ctrl``.

    When standalone:
    - Integrates the state directly.
    - Runs the standalone ``inverse_kinematics()``.
    - Clips joint angles to limits.

    Args:
        u: Control input (6,) — [dx, dy, dz, droll, dpitch, dyaw].

    Returns:
        Joint angle vector (num_dof,).
    """
    if self._engine is not None:
        current_ee = self._get_ee_pos()
        current_euler = self._get_ee_euler()
        target_ee = current_ee + u[:3] * self.dt
        target_euler = current_euler + u[3:6] * self.dt
        joint_targets = self.engine_ik(target_ee, target_euler=target_euler)
        for name, val in zip(self._joint_names, joint_targets):
            self._engine.set_joint_ctrl(name, val)
        self._last_joints = self.bk.array([self._engine.get_joint_qpos(n) for n in self._joint_names])
        ee = self._get_ee_pos()
        self.state = self.bk.hstack([ee, self._get_ee_euler()])
        return self._last_joints

    self.state = self.state + self.dt * u
    target = self._pose_to_transform(self.state)
    q = self.inverse_kinematics(target)
    self._last_joints = self.bk.clip(q, self.joint_limits[:, 0], self.joint_limits[:, 1])
    return self._last_joints

forward_kinematics

forward_kinematics(joint_angles)

Compute forward kinematics for a given joint configuration.

Chains the per-joint homogeneous transforms and returns the end-effector transform, joint positions, and joint axes.

Parameters:

Name Type Description Default
joint_angles

Joint angle vector (num_dof,).

required

Returns:

Type Description

Tuple of (T_ee, positions, axes):

  • T_ee: End-effector homogeneous transform (4, 4).
  • positions: List of joint position vectors (num_dof,) each (3,).
  • axes: List of joint axis direction vectors (num_dof,) each (3,).
Source code in src/shinro/plants/armrobot.py
def forward_kinematics(self, joint_angles):
    """Compute forward kinematics for a given joint configuration.

    Chains the per-joint homogeneous transforms and returns the
    end-effector transform, joint positions, and joint axes.

    Args:
        joint_angles: Joint angle vector (num_dof,).

    Returns:
        Tuple of (T_ee, positions, axes):
        - T_ee: End-effector homogeneous transform (4, 4).
        - positions: List of joint position vectors (num_dof,) each (3,).
        - axes: List of joint axis direction vectors (num_dof,) each (3,).
    """
    T_joints = self._homogenous_transform(joint_angles)
    T_cumulative = self.bk.eye(4)
    positions = []
    axes = []
    for i in range(self.num_dof):
        T_cumulative = T_cumulative @ T_joints[i]
        axis_local = {'x': [1, 0, 0], 'y': [0, 1, 0], 'z': [0, 0, 1]}[self.axes[i]]
        z_i = T_cumulative[:3, :3] @ self.bk.array(axis_local)
        positions.append(T_cumulative[:3, 3])
        axes.append(z_i)
    return T_cumulative, positions, axes

inverse_kinematics

inverse_kinematics(target_pose, max_iters: int = 100, q_init=None, tol: float = 0.0001, max_step: float = 0.2)

Compute inverse kinematics via damped pseudoinverse.

Iteratively minimizes the pose error (position + orientation) using the geometric Jacobian. The orientation error is computed from the rotation matrix trace.

Parameters:

Name Type Description Default
target_pose

Target homogeneous transform (4, 4).

required
max_iters int

Maximum number of IK iterations.

100
q_init

Initial joint angle guess. Defaults to last joint angles.

None
tol float

Convergence tolerance on position and orientation error.

0.0001
max_step float

Maximum joint angle change per iteration.

0.2

Returns:

Type Description

Joint angle vector (num_dof,) that achieves the target pose.

Source code in src/shinro/plants/armrobot.py
def inverse_kinematics(
    self,
    target_pose,
    max_iters: int = 100,
    q_init=None,
    tol: float = 1e-4,
    max_step: float = 0.2,
):
    """Compute inverse kinematics via damped pseudoinverse.

    Iteratively minimizes the pose error (position + orientation) using
    the geometric Jacobian. The orientation error is computed from the
    rotation matrix trace.

    Args:
        target_pose: Target homogeneous transform (4, 4).
        max_iters: Maximum number of IK iterations.
        q_init: Initial joint angle guess. Defaults to last joint angles.
        tol: Convergence tolerance on position and orientation error.
        max_step: Maximum joint angle change per iteration.

    Returns:
        Joint angle vector (num_dof,) that achieves the target pose.
    """
    q = q_init if q_init is not None else self.bk.copy(self._last_joints)
    for j in range(max_iters):
        T_cur, positions, axes = self.forward_kinematics(q)
        pos_err = target_pose[:3, 3] - T_cur[:3, 3]
        R_err = target_pose[:3, :3] @ T_cur[:3, :3].T
        angle = self.bk.arccos(self.bk.clip((self.bk.trace(R_err) - 1) / 2, -1, 1))

        if angle < tol and self.bk.norm(pos_err) < tol:
            break

        axis = self.bk.array([R_err[2, 1] - R_err[1, 2],
                              R_err[0, 2] - R_err[2, 0],
                              R_err[1, 0] - R_err[0, 1]])

        if self.bk.norm(axis) > 1e-6:
            ori_err = (axis / self.bk.norm(axis)) * angle
        else:
            ori_err = self.bk.zeros(3)

        v = self.bk.hstack([pos_err, ori_err])

        J = self._jacobian(q)
        dq = self.bk.pinv(J) @ v
        dq = self.bk.clip(dq, -max_step, max_step)
        q = q + dq
        q = self.bk.clip(q, self.joint_limits[:, 0], self.joint_limits[:, 1])

    return q

engine_ik

engine_ik(target_ee, target_euler=None, max_iters: int = 20, lam: float = 0.01, max_dq: float = 0.5)

Compute inverse kinematics using the physics engine's Jacobian.

Uses the engine's mesh-accurate Jacobian with damped least squares. With target_euler given, solves the full 6D pose error (position + orientation, world-frame log map); without it, solves position only (the historical 3D path, byte-identical to previous behavior). Sets joint positions in the engine and calls forward() each iteration.

Parameters:

Name Type Description Default
target_ee

Target end-effector position (3,).

required
target_euler

Optional target orientation as ZYX Euler [roll, pitch, yaw] (3,). When given, the 6D pose error is minimized.

None
max_iters int

Maximum IK iterations.

20
lam float

Damping factor for the least squares solve.

0.01
max_dq float

Maximum joint angle change per iteration.

0.5

Returns:

Type Description

Joint angle vector (num_dof,) that achieves the target pose.

Raises:

Type Description
RuntimeError

If no physics engine is attached.

Source code in src/shinro/plants/armrobot.py
def engine_ik(
    self,
    target_ee,
    target_euler=None,
    max_iters: int = 20,
    lam: float = 0.01,
    max_dq: float = 0.5,
):
    """Compute inverse kinematics using the physics engine's Jacobian.

    Uses the engine's mesh-accurate Jacobian with damped least squares.
    With ``target_euler`` given, solves the full 6D pose error (position +
    orientation, world-frame log map); without it, solves position only
    (the historical 3D path, byte-identical to previous behavior). Sets
    joint positions in the engine and calls ``forward()`` each iteration.

    Args:
        target_ee: Target end-effector position (3,).
        target_euler: Optional target orientation as ZYX Euler [roll,
            pitch, yaw] (3,). When given, the 6D pose error is minimized.
        max_iters: Maximum IK iterations.
        lam: Damping factor for the least squares solve.
        max_dq: Maximum joint angle change per iteration.

    Returns:
        Joint angle vector (num_dof,) that achieves the target pose.

    Raises:
        RuntimeError: If no physics engine is attached.
    """
    if self._engine is None:
        raise RuntimeError("engine_ik requires a physics engine (call physics_engine first)")

    current_ee = self._get_ee_pos()
    if target_euler is None:
        error = target_ee - current_ee
        jac_rows = 3
    else:
        current_euler = self._get_ee_euler()
        error = self.bk.hstack([target_ee - current_ee, self._orientation_error(target_euler, current_euler)])
        jac_rows = 6

    if self.bk.norm(error) < 0.001:
        return self.bk.array([self._engine.get_joint_qpos(n) for n in self._joint_names])

    current_joints = self.bk.array([self._engine.get_joint_qpos(n) for n in self._joint_names])

    for _ in range(max_iters):
        J = self._get_ee_jacobian()[:jac_rows, :]

        JJT = J @ J.T
        dq = J.T @ self.bk.solve(JJT + lam**2 * self.bk.eye(jac_rows), error)
        dq = self.bk.clip(dq, -max_dq, max_dq)
        current_joints = current_joints + dq
        current_joints = self.bk.clip(current_joints, self.joint_limits[:, 0], self.joint_limits[:, 1])

        for name, val in zip(self._joint_names, current_joints):
            self._engine.set_joint_qpos(name, val)
        self._engine.forward()

        current_ee = self._get_ee_pos()
        if target_euler is None:
            error = target_ee - current_ee
        else:
            current_euler = self._get_ee_euler()
            error = self.bk.hstack([target_ee - current_ee, self._orientation_error(target_euler, current_euler)])
        if self.bk.norm(error) < 0.001:
            break

    for name, val in zip(self._joint_names, current_joints):
        self._engine.set_joint_qpos(name, val)
    self._engine.forward()

    return current_joints

from_config classmethod

from_config(config, backend: ArrayBackend | None = None)

Create an ArmRobot from a TOML config dict or ArmRobotConfig.

Config fields

joint_group: Name of the joint group in joint_groups. num_dof: Number of degrees of freedom. dt: Time step. joint_offsets: List of link offset vectors. rot_axes: List of rotation axes. ee_body_name: Optional end-effector body name.

Requires a runtime-injected engine (RobotSim merges it into the config dict); standalone use without an engine is not supported.

Parameters:

Name Type Description Default
config

TOML config dict (with runtime-injected engine and joint_groups) or ArmRobotConfig.

required
backend ArrayBackend | None

Array backend. Defaults to NumpyBackend.

None

Returns:

Type Description

ArmRobot instance.

Source code in src/shinro/plants/armrobot.py
@classmethod
def from_config(cls, config, backend: ArrayBackend | None = None):
    """Create an ArmRobot from a TOML config dict or :class:`ArmRobotConfig`.

    Config fields:
        joint_group: Name of the joint group in ``joint_groups``.
        num_dof: Number of degrees of freedom.
        dt: Time step.
        joint_offsets: List of link offset vectors.
        rot_axes: List of rotation axes.
        ee_body_name: Optional end-effector body name.

    Requires a runtime-injected ``engine`` (RobotSim merges it into the
    config dict); standalone use without an engine is not supported.

    Args:
        config: TOML config dict (with runtime-injected ``engine`` and
            ``joint_groups``) or ArmRobotConfig.
        backend: Array backend. Defaults to NumpyBackend.

    Returns:
        ArmRobot instance.
    """
    bk = backend or NumpyBackend()
    clean, runtime = (
        strip_runtime_keys(config, ("engine", "joint_groups")) if isinstance(config, dict) else (config, {})
    )
    cfg = cls.parse_config(clean)
    engine = runtime.get("engine")
    joint_groups = runtime.get("joint_groups")
    if engine is None or joint_groups is None:
        raise ValueError(
            "ArmRobot requires a runtime-injected 'engine' and 'joint_groups' "
            "(RobotSim merges them into the config dict) — standalone use is not supported."
        )
    joint_names = joint_groups[cfg.joint_group]
    limits = np.array([engine.get_joint_limits(n) for n in joint_names])
    plant = cls(
        num_dof=cfg.num_dof,
        dt=cfg.dt,
        joint_limits=bk.from_numpy(limits),
        joint_offsets=bk.from_numpy(np.array(cfg.joint_offsets)),
        rot_axes=cfg.rot_axes,
        joint_names=joint_names,
        ee_body_name=cfg.ee_body_name,
        backend=bk,
    )
    plant.physics_engine(engine)
    return plant

HolonomicMobileRobot

Bases: Plant

Holonomic mobile robot with omni-wheel kinematics.

Models a robot with N wheels arranged symmetrically around a center. Maps world-frame velocity commands \([v_x, v_y, \omega]\) to individual wheel speeds via the kinematic matrix \(A_{\text{kin}}\).

The forward kinematics matrix \(A_{\text{kin}}\) maps body-frame velocities to wheel speeds:

\[ \omega_{\text{wheels}} = \frac{1}{r} A_{\text{kin}} [v_x, v_y, \omega]^T \]

where each row of \(A_{\text{kin}}\) is \([\sin(\theta_i), -\cos(\theta_i), -R]\) for wheel i at angle \(\theta_i\).

When a MuJoCo engine is attached, step() also stores wheel rotation deltas for visual rolling in the physics simulation.

The kinematics matrix is built once in __init__ using numpy (scalar trig from Python floats), then converted to the backend via bk.from_numpy. All per-step operations use bk.xxx.

Parameters:

Name Type Description Default
num_wheels int

Number of wheels (e.g., 3 for omni, 4 for mecanum).

required
radius_robots float

Distance from robot center to each wheel (m).

required
gamma float

Angle of the first wheel relative to the robot base (rad).

required
radius_wheels float

Radius of each wheel (m).

required
dt float

Simulation time step (s).

required
backend ArrayBackend | None

Array backend. Defaults to NumpyBackend.

None
Source code in src/shinro/plants/holonomicmobilerobot.py
def __init__(
    self,
    num_wheels: int,
    radius_robots: float,
    gamma: float,
    radius_wheels: float,
    dt: float,
    drive_joints: list[str] | None = None,
    backend: ArrayBackend | None = None,
):
    self.bk = backend or NumpyBackend()
    self.n = num_wheels
    self.R = radius_robots
    self.gamma = gamma
    self.r = radius_wheels
    self.dt = dt
    self.state = self.bk.zeros(3)
    self.A_kinematics, self.A_pinv_kin = self._build_kinematics()
    self._engine = None
    self._drive_joints = drive_joints or []
    self._target_wheel_delta = None

physics_engine

physics_engine(engine)

Attach a physics engine.

After attachment, step() uses the engine for visual wheel rolling. The backend is inherited from the engine.

Parameters:

Name Type Description Default
engine

PhysicsEngine instance or None to detach.

required
Source code in src/shinro/plants/holonomicmobilerobot.py
def physics_engine(self, engine):
    """Attach a physics engine.

    After attachment, ``step()`` uses the engine for visual wheel rolling.
    The backend is inherited from the engine.

    Args:
        engine: PhysicsEngine instance or None to detach.
    """
    self._engine = engine
    if engine is not None:
        self.bk = engine.backend

step

step(u_world)

Update robot state and compute wheel speeds.

Transforms the world-frame velocity command to body frame, computes wheel speeds via the kinematic matrix, and integrates the state.

Parameters:

Name Type Description Default
u_world

Desired velocity \([v_x, v_y, \omega]\) in world frame (3,).

required

Returns:

Type Description

Wheel speed vector (n_wheels,).

Source code in src/shinro/plants/holonomicmobilerobot.py
def step(self, u_world):
    """Update robot state and compute wheel speeds.

    Transforms the world-frame velocity command to body frame, computes
    wheel speeds via the kinematic matrix, and integrates the state.

    Args:
        u_world: Desired velocity :math:`[v_x, v_y, \\omega]` in world
            frame (3,).

    Returns:
        Wheel speed vector (n_wheels,).
    """
    theta = self.state[2]
    rot_matrix = self._rot_matrix(theta)
    u_body = rot_matrix @ u_world
    wheel_speeds = (1.0 / self.r) * self.A_kinematics @ u_body
    self.state = self.state + u_world * self.dt
    if self._engine is not None:
        self._target_wheel_delta = wheel_speeds * self.dt
    return wheel_speeds

post_engine_step

post_engine_step(engine) -> None

Reconcile the engine's free-joint base with the plant's integrated pose.

The plant self-integrates pose analytically; the MuJoCo base exists for physics/rendering only. After engine.step() this writes (x, y) into the free joint, pins the base quaternion upright and zeros the floating-base velocities — the chassis follows the plant's kinematics, not contact dynamics — then deposits the pending wheel-rotation deltas onto the drive joints for visual rolling (consumed once per step).

No-op for engines without a free joint (e.g. a statically mounted fixture): plant state remains the source of truth either way.

Parameters:

Name Type Description Default
engine

The physics engine stepping the world.

required
Source code in src/shinro/plants/holonomicmobilerobot.py
def post_engine_step(self, engine) -> None:
    """Reconcile the engine's free-joint base with the plant's integrated pose.

    The plant self-integrates pose analytically; the MuJoCo base exists for
    physics/rendering only. After ``engine.step()`` this writes (x, y) into
    the free joint, pins the base quaternion upright and zeros the
    floating-base velocities — the chassis follows the plant's kinematics,
    not contact dynamics — then deposits the pending wheel-rotation deltas
    onto the drive joints for visual rolling (consumed once per step).

    No-op for engines without a free joint (e.g. a statically mounted
    fixture): plant state remains the source of truth either way.

    Args:
        engine: The physics engine stepping the world.
    """
    if self._engine is None or not engine.has_free_joint:
        return

    state = self.bk.to_numpy(self.state)
    engine.pin_free_base(float(state[0]), float(state[1]))

    if self._target_wheel_delta is not None:
        deltas = self.bk.to_numpy(self._target_wheel_delta)
        for name, delta in zip(self._drive_joints, deltas):
            engine.set_joint_qpos(name, engine.get_joint_qpos(name) + float(delta))
        self._target_wheel_delta = None

set_pose

set_pose(x: float, y: float, theta: float)

Set the robot's pose directly.

Parameters:

Name Type Description Default
x float

X position (m).

required
y float

Y position (m).

required
theta float

Orientation (rad).

required
Source code in src/shinro/plants/holonomicmobilerobot.py
def set_pose(self, x: float, y: float, theta: float):
    """Set the robot's pose directly.

    Args:
        x: X position (m).
        y: Y position (m).
        theta: Orientation (rad).
    """
    self.state = self.bk.array([x, y, theta])

get_state

get_state()

Return the current pose \([x, y, \theta]\).

Returns:

Type Description

State vector (3,) — [x, y, theta].

Source code in src/shinro/plants/holonomicmobilerobot.py
def get_state(self):
    """Return the current pose :math:`[x, y, \\theta]`.

    Returns:
        State vector (3,) — [x, y, theta].
    """
    return self.bk.copy(self.state)

get_model

get_model()

Get the discrete-time state-space model.

Returns:

Type Description

Tuple of (A, B) where A = I_3 and B = dt * I_3.

Source code in src/shinro/plants/holonomicmobilerobot.py
def get_model(self):
    """Get the discrete-time state-space model.

    Returns:
        Tuple of (A, B) where A = I_3 and B = dt * I_3.
    """
    A = self.bk.eye(3)
    B = self.dt * self.bk.eye(3)
    return A, B

from_config classmethod

from_config(config, backend: ArrayBackend | None = None)

Create a HolonomicMobileRobot from a TOML config dict or HolonomicMobileRobotConfig.

Config fields

num_wheels: Number of wheels. radius_robots: Distance from center to each wheel (m). gamma: First wheel angle offset (rad). radius_wheels: Wheel radius (m). dt: Time step.

Parameters:

Name Type Description Default
config

TOML config dict (may carry a runtime-injected engine) or HolonomicMobileRobotConfig.

required
backend ArrayBackend | None

Array backend. Defaults to NumpyBackend.

None

Returns:

Type Description

HolonomicMobileRobot instance.

Source code in src/shinro/plants/holonomicmobilerobot.py
@classmethod
def from_config(cls, config, backend: ArrayBackend | None = None):
    """Create a HolonomicMobileRobot from a TOML config dict or :class:`HolonomicMobileRobotConfig`.

    Config fields:
        num_wheels: Number of wheels.
        radius_robots: Distance from center to each wheel (m).
        gamma: First wheel angle offset (rad).
        radius_wheels: Wheel radius (m).
        dt: Time step.

    Args:
        config: TOML config dict (may carry a runtime-injected ``engine``)
            or HolonomicMobileRobotConfig.
        backend: Array backend. Defaults to NumpyBackend.

    Returns:
        HolonomicMobileRobot instance.
    """
    bk = backend or NumpyBackend()
    clean, runtime = (
        strip_runtime_keys(config, ("engine", "joint_groups")) if isinstance(config, dict) else (config, {})
    )
    cfg = cls.parse_config(clean)
    drive_joints = cfg.drive_joints
    if drive_joints is None:
        drive_joints = runtime.get("joint_groups", {}).get("drive_joints")
    plant = cls(
        num_wheels=cfg.num_wheels,
        radius_robots=cfg.radius_robots,
        gamma=cfg.gamma,
        radius_wheels=cfg.radius_wheels,
        dt=cfg.dt,
        drive_joints=drive_joints,
        backend=bk,
    )
    engine = runtime.get("engine")
    if engine is not None:
        plant.physics_engine(engine)
    return plant

InvertedPendulum

Bases: Plant

2D inverted pendulum with standalone analytical dynamics and optional MuJoCo engine.

Models a simple pendulum with a point mass at the end of a massless rod, hinged at the origin. The state is the angle from upright \([\theta, \dot{\theta}]\) and the control is torque at the pivot \([\tau]\).

Supports two modes:

  1. Standalone — integrates the analytical dynamics using semi-implicit Euler (velocity updated before position).
  2. Physics engine — attaches a MuJoCo engine for mesh-accurate simulation.

The continuous-time dynamics are:

\[ \ddot{\theta} = \frac{g}{l} \sin(\theta) + \frac{\tau}{m l^2} - \frac{b}{m l^2} \dot{\theta} \]

The linearized model is computed about an operating point supplied to get_model, defaulting to the upright equilibrium \((\theta=0, \dot{\theta}=0)\) where the continuous-time Jacobians are:

\[ A = \begin{bmatrix} 0 & 1 \\ g/l & -b/(m l^2) \end{bmatrix}, \quad B = \begin{bmatrix} 0 \\ 1/(m l^2) \end{bmatrix} \]

Parameters:

Name Type Description Default
mass float

Mass of the pendulum bob (kg).

0.1
length float

Length of the pendulum rod (m).

0.5
damping float

Linear damping coefficient at the pivot (Nms/rad).

0.0
gravity float

Gravitational acceleration (m/s^2).

9.81
dt float

Time step in seconds.

0.01
state_bounds tuple | None

Optional (min, max) bounds for state clipping. Each is an array of shape (2,).

None
backend ArrayBackend | None

Array backend. Defaults to NumpyBackend.

None
Source code in src/shinro/plants/inverted_pendulum.py
def __init__(
    self,
    mass: float = 0.1,
    length: float = 0.5,
    damping: float = 0.0,
    gravity: float = 9.81,
    dt: float = 0.01,
    state_bounds: tuple | None = None,
    backend: ArrayBackend | None = None,
):
    self.bk = backend or NumpyBackend()
    self.m = mass
    self.l = length
    self.b = damping
    self.g = gravity
    self.dt = dt
    self.state_bounds = state_bounds
    self.input_dim = 1
    self.state = self.bk.zeros(2)
    self._engine = None

physics_engine

physics_engine(engine: PhysicsEngine | None)

Attach or detach a physics engine.

When attached, the backend is inherited from the engine and the state is reset to zeros. When detached, the backend reverts to NumpyBackend.

Parameters:

Name Type Description Default
engine PhysicsEngine | None

PhysicsEngine instance or None to detach.

required
Source code in src/shinro/plants/inverted_pendulum.py
def physics_engine(self, engine: PhysicsEngine | None):
    """Attach or detach a physics engine.

    When attached, the backend is inherited from the engine and the
    state is reset to zeros. When detached, the backend reverts to
    NumpyBackend.

    Args:
        engine: PhysicsEngine instance or None to detach.
    """
    self._engine = engine
    if engine is not None:
        self.bk = engine.backend
        self.state = self.bk.zeros(2)
    else:
        self.bk = NumpyBackend()
        self.state = self.bk.zeros(2)

get_state

get_state()

Get the current state \([\theta, \dot{\theta}]\).

When a physics engine is attached, reads joint position and velocity from the engine. Otherwise returns a copy of the internal state.

Returns:

Type Description

State vector (2,) — [theta, theta_dot].

Source code in src/shinro/plants/inverted_pendulum.py
def get_state(self):
    """Get the current state :math:`[\\theta, \\dot{\\theta}]`.

    When a physics engine is attached, reads joint position and velocity
    from the engine. Otherwise returns a copy of the internal state.

    Returns:
        State vector (2,) — [theta, theta_dot].
    """
    if self._engine is not None:
        qpos = self._engine.get_joint_qpos("hinge")
        qvel = self._engine.get_joint_vel("hinge")
        return self.bk.array([qpos, qvel])
    return self.bk.copy(self.state)

get_model

get_model(x0=None, u0=None, eps=1e-06)

Get the discrete-time state-space model around an operating point.

Linearizes the continuous-time dynamics \(f(x, u) = \dot{x}\) around (x0, u0) using central finite differences via shinro.utils.linearization.linearize_plant, then Euler-discretizes at the plant's dt. When x0/u0 are omitted, defaults to the upright equilibrium \((\theta=0, \dot{\theta}=0)\) with zero control.

Parameters:

Name Type Description Default
x0

Operating point state (2,) — [theta, theta_dot]. Defaults to zeros (upright equilibrium).

None
u0

Operating point control (1,) — [tau]. Defaults to zeros.

None
eps

Step size for finite differences.

1e-06

Returns:

Type Description

Tuple of (A, B) where A = I + dt·∂f/∂x is (2, 2) and

B = dt·∂f/∂u is (2, 1).

Source code in src/shinro/plants/inverted_pendulum.py
def get_model(self, x0=None, u0=None, eps=1e-6):
    """Get the discrete-time state-space model around an operating point.

    Linearizes the continuous-time dynamics :math:`f(x, u) = \\dot{x}`
    around ``(x0, u0)`` using central finite differences via
    :func:`shinro.utils.linearization.linearize_plant`, then
    Euler-discretizes at the plant's ``dt``. When ``x0``/``u0`` are
    omitted, defaults to the upright equilibrium
    :math:`(\\theta=0, \\dot{\\theta}=0)` with zero control.

    Args:
        x0: Operating point state (2,) — [theta, theta_dot].
            Defaults to zeros (upright equilibrium).
        u0: Operating point control (1,) — [tau]. Defaults to zeros.
        eps: Step size for finite differences.

    Returns:
        Tuple of (A, B) where A = I + dt·∂f/∂x is (2, 2) and
        B = dt·∂f/∂u is (2, 1).
    """
    A_c, B_c = linearize_plant(self, x0, u0, eps=eps)
    return discretize_euler(A_c, B_c, self.dt, backend=self.bk)

dynamics

dynamics(state, control)

Continuous-time dynamics \(\dot{x} = f(x, u)\).

Parameters:

Name Type Description Default
state

State vector (2,) — [theta, theta_dot].

required
control

Control vector (1,) or scalar — [tau].

required

Returns:

Type Description

Time derivative of the state (2,) — [theta_dot, theta_ddot].

Source code in src/shinro/plants/inverted_pendulum.py
def dynamics(self, state, control):
    """Continuous-time dynamics :math:`\\dot{x} = f(x, u)`.

    Args:
        state: State vector (2,) — [theta, theta_dot].
        control: Control vector (1,) or scalar — [tau].

    Returns:
        Time derivative of the state (2,) — [theta_dot, theta_ddot].
    """
    theta, theta_dot = state[0], state[1]
    tau = control[0] if hasattr(control, '__len__') else control
    theta_ddot = (self.g / self.l) * self.bk.sin(theta) + tau / (self.m * self.l**2) - (self.b / (self.m * self.l**2)) * theta_dot
    return self.bk.stack([theta_dot, theta_ddot])

step

step(u)

Execute one control step.

When a physics engine is attached, sets the torque actuator and advances the engine. Otherwise integrates the analytical dynamics using semi-implicit Euler.

Parameters:

Name Type Description Default
u

Control input (1,) or scalar — torque at pivot (Nm).

required

Returns:

Type Description

New state vector (2,) — [theta, theta_dot].

Source code in src/shinro/plants/inverted_pendulum.py
def step(self, u):
    """Execute one control step.

    When a physics engine is attached, sets the torque actuator and
    advances the engine. Otherwise integrates the analytical dynamics
    using semi-implicit Euler.

    Args:
        u: Control input (1,) or scalar — torque at pivot (Nm).

    Returns:
        New state vector (2,) — [theta, theta_dot].
    """
    if self._engine is not None:
        self._engine.set_joint_ctrl("hinge", u[0] if hasattr(u, '__len__') else u)
        self._engine.step()
        self.state = self.get_state()
        return self.state

    theta, theta_dot = self.state[0], self.state[1]
    tau = u[0] if hasattr(u, '__len__') else u
    theta_ddot = (self.g / self.l) * self.bk.sin(theta) + tau / (self.m * self.l**2) - (self.b / (self.m * self.l**2)) * theta_dot
    theta_dot_new = theta_dot + theta_ddot * self.dt
    theta_new = theta + theta_dot_new * self.dt
    self.state = self.bk.array([theta_new, theta_dot_new])
    if self.state_bounds is not None:
        self.state = self.bk.clip(self.state, self.state_bounds[0], self.state_bounds[1])
    return self.state

from_config classmethod

from_config(config, backend: ArrayBackend | None = None)

Create an InvertedPendulum from a TOML config dict or InvertedPendulumConfig.

Config fields

mass: Pendulum bob mass (kg). length: Pendulum rod length (m). damping: Linear damping coefficient. gravity: Gravitational acceleration (m/s^2). dt: Time step. state_bounds: Optional dict with min and max lists.

Parameters:

Name Type Description Default
config

TOML config dict (may carry a runtime-injected engine) or InvertedPendulumConfig.

required
backend ArrayBackend | None

Array backend. Defaults to NumpyBackend.

None

Returns:

Type Description

InvertedPendulum instance.

Source code in src/shinro/plants/inverted_pendulum.py
@classmethod
def from_config(cls, config, backend: ArrayBackend | None = None):
    """Create an InvertedPendulum from a TOML config dict or :class:`InvertedPendulumConfig`.

    Config fields:
        mass: Pendulum bob mass (kg).
        length: Pendulum rod length (m).
        damping: Linear damping coefficient.
        gravity: Gravitational acceleration (m/s^2).
        dt: Time step.
        state_bounds: Optional dict with ``min`` and ``max`` lists.

    Args:
        config: TOML config dict (may carry a runtime-injected ``engine``)
            or InvertedPendulumConfig.
        backend: Array backend. Defaults to NumpyBackend.

    Returns:
        InvertedPendulum instance.
    """
    bk = backend or NumpyBackend()
    clean, runtime = (
        strip_runtime_keys(config, ("engine", "joint_groups")) if isinstance(config, dict) else (config, {})
    )
    cfg = cls.parse_config(clean)
    state_bounds = None
    if cfg.state_bounds is not None:
        sb = strict_from_dict(BoundsConfig, cfg.state_bounds, "InvertedPendulum.state_bounds")
        state_bounds = (
            bk.array(sb.min if sb.min is not None else [-3.14, -10.0]),
            bk.array(sb.max if sb.max is not None else [3.14, 10.0]),
        )
    plant = cls(
        mass=cfg.mass,
        length=cfg.length,
        damping=cfg.damping,
        gravity=cfg.gravity,
        dt=cfg.dt,
        state_bounds=state_bounds,
        backend=bk,
    )
    engine = runtime.get("engine")
    if engine is not None:
        plant.physics_engine(engine)
    return plant

CartPole

Bases: Plant

4D cart-pole system with standalone analytical dynamics and optional MuJoCo engine.

Models a cart on a frictionless track with a pole hinged on top. The state is \([x, \dot{x}, \theta, \dot{\theta}]\) (cart position, cart velocity, pole angle from upright, pole angular velocity) and the control is horizontal force on the cart \([F]\).

Supports two modes:

  1. Standalone — integrates the coupled analytical dynamics using semi-implicit Euler (velocities updated before positions).
  2. Physics engine — attaches a MuJoCo engine for mesh-accurate simulation.

The equations of motion for the coupled system are:

\[ \ddot{\theta} = \frac{g \sin\theta - \cos\theta \left(\frac{F + m l \dot{\theta}^2 \sin\theta}{M + m}\right)} {l - \frac{m l \cos^2\theta}{M + m}} \ddot{x} = \frac{F + m l \left(\dot{\theta}^2 \sin\theta - \ddot{\theta} \cos\theta\right)}{M + m} \]

The linearized model is computed about an operating point supplied to get_model, defaulting to the upright equilibrium \((x=0, \dot{x}=0, \theta=0, \dot{\theta}=0)\) where the continuous-time Jacobians are:

\[ A = \begin{bmatrix} 0 & 1 & 0 & 0 \\ 0 & 0 & -mg/M & 0 \\ 0 & 0 & 0 & 1 \\ 0 & 0 & (M+m)g/(Ml) & -b/(M l^2) \end{bmatrix}, \quad B = \begin{bmatrix} 0 \\ 1/M \\ 0 \\ -1/(M l) \end{bmatrix} \]

Parameters:

Name Type Description Default
cart_mass float

Mass of the cart (kg).

0.5
pole_mass float

Mass of the pole (kg).

0.1
pole_length float

Length of the pole (m).

0.5
damping float

Linear damping coefficient at the pole hinge (Nms/rad).

0.0
gravity float

Gravitational acceleration (m/s^2).

9.81
dt float

Time step in seconds.

0.01
track_limits tuple | None

Optional (min, max) bounds for cart position (m).

None
backend ArrayBackend | None

Array backend. Defaults to NumpyBackend.

None
Source code in src/shinro/plants/cartpole.py
def __init__(
    self,
    cart_mass: float = 0.5,
    pole_mass: float = 0.1,
    pole_length: float = 0.5,
    damping: float = 0.0,
    gravity: float = 9.81,
    dt: float = 0.01,
    track_limits: tuple | None = None,
    backend: ArrayBackend | None = None,
):
    self.bk = backend or NumpyBackend()
    self.M = cart_mass
    self.m = pole_mass
    self.l = pole_length
    self.b = damping
    self.g = gravity
    self.dt = dt
    self.track_limits = track_limits
    self.input_dim = 1
    self.state = self.bk.zeros(4)
    self._engine = None

physics_engine

physics_engine(engine: PhysicsEngine | None)

Attach or detach a physics engine.

When attached, the backend is inherited from the engine and the state is reset to zeros. When detached, the backend reverts to NumpyBackend.

Parameters:

Name Type Description Default
engine PhysicsEngine | None

PhysicsEngine instance or None to detach.

required
Source code in src/shinro/plants/cartpole.py
def physics_engine(self, engine: PhysicsEngine | None):
    """Attach or detach a physics engine.

    When attached, the backend is inherited from the engine and the
    state is reset to zeros. When detached, the backend reverts to
    NumpyBackend.

    Args:
        engine: PhysicsEngine instance or None to detach.
    """
    self._engine = engine
    if engine is not None:
        self.bk = engine.backend
        self.state = self.bk.zeros(4)
    else:
        self.bk = NumpyBackend()
        self.state = self.bk.zeros(4)

get_state

get_state()

Get the current state \([x, \dot{x}, \theta, \dot{\theta}]\).

When a physics engine is attached, reads joint positions and velocities from the engine. Otherwise returns a copy of the internal state.

Returns:

Type Description

State vector (4,) — [x, x_dot, theta, theta_dot].

Source code in src/shinro/plants/cartpole.py
def get_state(self):
    """Get the current state :math:`[x, \\dot{x}, \\theta, \\dot{\\theta}]`.

    When a physics engine is attached, reads joint positions and
    velocities from the engine. Otherwise returns a copy of the
    internal state.

    Returns:
        State vector (4,) — [x, x_dot, theta, theta_dot].
    """
    if self._engine is not None:
        x = self._engine.get_joint_qpos("slider")
        x_dot = self._engine.get_joint_vel("slider")
        theta = self._engine.get_joint_qpos("hinge")
        theta_dot = self._engine.get_joint_vel("hinge")
        return self.bk.array([x, x_dot, theta, theta_dot])
    return self.bk.copy(self.state)

get_model

get_model(x0=None, u0=None, eps=1e-06)

Get the discrete-time state-space model around an operating point.

Linearizes the continuous-time dynamics \(f(x, u) = \dot{x}\) around (x0, u0) using central finite differences via shinro.utils.linearization.linearize_plant, then Euler-discretizes at the plant's dt. When x0/u0 are omitted, defaults to the upright equilibrium \((x=0, \dot{x}=0, \theta=0, \dot{\theta}=0)\) with zero control.

Parameters:

Name Type Description Default
x0

Operating point state (4,) — [x, x_dot, theta, theta_dot]. Defaults to zeros (upright equilibrium).

None
u0

Operating point control (1,) — [F]. Defaults to zeros.

None
eps

Step size for finite differences.

1e-06

Returns:

Type Description

Tuple of (A, B) where A = I + dt·∂f/∂x is (4, 4) and

B = dt·∂f/∂u is (4, 1).

Source code in src/shinro/plants/cartpole.py
def get_model(self, x0=None, u0=None, eps=1e-6):
    """Get the discrete-time state-space model around an operating point.

    Linearizes the continuous-time dynamics :math:`f(x, u) = \\dot{x}`
    around ``(x0, u0)`` using central finite differences via
    :func:`shinro.utils.linearization.linearize_plant`, then
    Euler-discretizes at the plant's ``dt``. When ``x0``/``u0`` are
    omitted, defaults to the upright equilibrium
    :math:`(x=0, \\dot{x}=0, \\theta=0, \\dot{\\theta}=0)` with zero
    control.

    Args:
        x0: Operating point state (4,) — [x, x_dot, theta, theta_dot].
            Defaults to zeros (upright equilibrium).
        u0: Operating point control (1,) — [F]. Defaults to zeros.
        eps: Step size for finite differences.

    Returns:
        Tuple of (A, B) where A = I + dt·∂f/∂x is (4, 4) and
        B = dt·∂f/∂u is (4, 1).
    """
    A_c, B_c = linearize_plant(self, x0, u0, eps=eps)
    return discretize_euler(A_c, B_c, self.dt, backend=self.bk)

dynamics

dynamics(state, control)

Continuous-time dynamics \(\dot{x} = f(x, u)\).

Parameters:

Name Type Description Default
state

State vector (4,) — [x, x_dot, theta, theta_dot].

required
control

Control vector (1,) or scalar — [F].

required

Returns:

Type Description

Time derivative of the state (4,) — [x_dot, x_ddot, theta_dot, theta_ddot].

Source code in src/shinro/plants/cartpole.py
def dynamics(self, state, control):
    """Continuous-time dynamics :math:`\\dot{x} = f(x, u)`.

    Args:
        state: State vector (4,) — [x, x_dot, theta, theta_dot].
        control: Control vector (1,) or scalar — [F].

    Returns:
        Time derivative of the state (4,) — [x_dot, x_ddot, theta_dot, theta_ddot].
    """
    x, x_dot, theta, theta_dot = state[0], state[1], state[2], state[3]
    F = control[0] if hasattr(control, '__len__') else control
    x_ddot, theta_ddot = self._compute_accels(x, theta, x_dot, theta_dot, F)
    return self.bk.stack([x_dot, x_ddot, theta_dot, theta_ddot])

step

step(u)

Execute one control step.

When a physics engine is attached, sets the cart force actuator and advances the engine. Otherwise integrates the coupled analytical dynamics using semi-implicit Euler.

Parameters:

Name Type Description Default
u

Control input (1,) or scalar — horizontal force on cart (N).

required

Returns:

Type Description

New state vector (4,) — [x, x_dot, theta, theta_dot].

Source code in src/shinro/plants/cartpole.py
def step(self, u):
    """Execute one control step.

    When a physics engine is attached, sets the cart force actuator and
    advances the engine. Otherwise integrates the coupled analytical
    dynamics using semi-implicit Euler.

    Args:
        u: Control input (1,) or scalar — horizontal force on cart (N).

    Returns:
        New state vector (4,) — [x, x_dot, theta, theta_dot].
    """
    if self._engine is not None:
        self._engine.set_joint_ctrl("slider", u[0] if hasattr(u, '__len__') else u)
        self._engine.step()
        self.state = self.get_state()
        return self.state

    x, x_dot, theta, theta_dot = self.state[0], self.state[1], self.state[2], self.state[3]
    F = u[0] if hasattr(u, '__len__') else u
    x_ddot, theta_ddot = self._compute_accels(x, theta, x_dot, theta_dot, F)
    theta_dot_new = theta_dot + theta_ddot * self.dt
    x_dot_new = x_dot + x_ddot * self.dt
    theta_new = theta + theta_dot_new * self.dt
    x_new = x + x_dot_new * self.dt
    self.state = self.bk.array([x_new, x_dot_new, theta_new, theta_dot_new])
    if self.track_limits is not None:
        self.state = self.bk.array([
            self.bk.clip(x_new, self.track_limits[0], self.track_limits[1]),
            x_dot_new, theta_new, theta_dot_new,
        ])
    return self.state

from_config classmethod

from_config(config, backend: ArrayBackend | None = None)

Create a CartPole from a TOML config dict or CartPoleConfig.

Config fields

cart_mass: Mass of the cart (kg). pole_mass: Mass of the pole (kg). pole_length: Length of the pole (m). damping: Linear damping coefficient. gravity: Gravitational acceleration (m/s^2). dt: Time step. track_limits: Optional list of [min, max] for cart position.

Parameters:

Name Type Description Default
config

TOML config dict (may carry a runtime-injected engine) or CartPoleConfig.

required
backend ArrayBackend | None

Array backend. Defaults to NumpyBackend.

None

Returns:

Type Description

CartPole instance.

Source code in src/shinro/plants/cartpole.py
@classmethod
def from_config(cls, config, backend: ArrayBackend | None = None):
    """Create a CartPole from a TOML config dict or :class:`CartPoleConfig`.

    Config fields:
        cart_mass: Mass of the cart (kg).
        pole_mass: Mass of the pole (kg).
        pole_length: Length of the pole (m).
        damping: Linear damping coefficient.
        gravity: Gravitational acceleration (m/s^2).
        dt: Time step.
        track_limits: Optional list of [min, max] for cart position.

    Args:
        config: TOML config dict (may carry a runtime-injected ``engine``)
            or CartPoleConfig.
        backend: Array backend. Defaults to NumpyBackend.

    Returns:
        CartPole instance.
    """
    bk = backend or NumpyBackend()
    clean, runtime = (
        strip_runtime_keys(config, ("engine", "joint_groups")) if isinstance(config, dict) else (config, {})
    )
    cfg = cls.parse_config(clean)
    track_limits = (cfg.track_limits[0], cfg.track_limits[1]) if cfg.track_limits is not None else None
    plant = cls(
        cart_mass=cfg.cart_mass,
        pole_mass=cfg.pole_mass,
        pole_length=cfg.pole_length,
        damping=cfg.damping,
        gravity=cfg.gravity,
        dt=cfg.dt,
        track_limits=track_limits,
        backend=bk,
    )
    engine = runtime.get("engine")
    if engine is not None:
        plant.physics_engine(engine)
    return plant

DoublePendulum

Bases: Plant

4D planar double pendulum with standalone analytical dynamics and optional MuJoCo engine.

Models two point masses at the ends of two massless rods, hinged in series. The state is \([\theta_1, \theta_2, \omega_1, \omega_2]\) (angle of each rod from the downward vertical and its angular velocity) and the control is joint torques \([\tau_1, \tau_2]\) applied at each hinge. The rest equilibrium \(\theta=0\) is stable (hanging down).

Supports two modes:

  1. Standalone — integrates the analytical dynamics using semi-implicit Euler (velocities updated before positions).
  2. Physics engine — attaches a MuJoCo engine for mesh-accurate simulation (expects an MJCF model with hinge_1/hinge_2 joints and torque_1/torque_2 motor actuators).

The equations of motion are the standard double pendulum manipulator form:

\[ M(\theta) \ddot{\theta} + C(\theta, \dot{\theta}) \dot{\theta} + G(\theta) = \tau \]

where \(M\) is the mass matrix, \(C\) the Coriolis matrix, and \(G\) the gravity vector. The angular acceleration follows from \(\ddot{\theta} = M^{-1}(\tau - C\dot{\theta} - G)\).

Parameters:

Name Type Description Default
mass_top float

Mass of the top pendulum bob (kg).

0.1
mass_bottom float

Mass of the bottom pendulum bob (kg).

0.1
length_top float

Length of the top rod (m).

0.5
length_bottom float

Length of the bottom rod (m).

0.5
dt float

Time step in seconds.

0.01
g float

Gravitational acceleration (m/s^2).

9.81
state_bounds tuple | None

Optional (min, max) bounds for state clipping. Each is an array of shape (4,).

None
backend ArrayBackend | None

Array backend. Defaults to NumpyBackend.

None
Source code in src/shinro/plants/double_pendulum.py
def __init__(
    self,
    mass_top: float = 0.1,
    mass_bottom: float = 0.1,
    length_top: float = 0.5,
    length_bottom: float = 0.5,
    dt: float = 0.01,
    g: float = 9.81,
    state_bounds: tuple | None = None,
    backend: ArrayBackend | None = None,
):
    if mass_top <= 0 or mass_bottom <= 0:
        raise ValueError("Pendulum masses must be positive.")
    if length_top <= 0 or length_bottom <= 0:
        raise ValueError("Pendulum lengths must be positive.")
    if dt <= 0:
        raise ValueError("dt must be positive.")
    self.bk = backend or NumpyBackend()
    self.m1 = mass_top
    self.m2 = mass_bottom
    self.l1 = length_top
    self.l2 = length_bottom
    self.dt = dt
    self.g = g
    self.input_dim = 2
    self.state_bounds = state_bounds
    self.state = self.bk.zeros(4)
    self._engine = None

physics_engine

physics_engine(engine: PhysicsEngine | None)

Attach or detach a physics engine.

When attached, the backend is inherited from the engine and the state is reset to zeros. When detached, the backend reverts to NumpyBackend.

Parameters:

Name Type Description Default
engine PhysicsEngine | None

PhysicsEngine instance or None to detach.

required
Source code in src/shinro/plants/double_pendulum.py
def physics_engine(self, engine: PhysicsEngine | None):
    """Attach or detach a physics engine.

    When attached, the backend is inherited from the engine and the
    state is reset to zeros. When detached, the backend reverts to
    NumpyBackend.

    Args:
        engine: PhysicsEngine instance or None to detach.
    """
    self._engine = engine
    if engine is not None:
        self.bk = engine.backend
        self.state = self.bk.zeros(4)
    else:
        self.bk = NumpyBackend()
        self.state = self.bk.zeros(4)

dynamics

dynamics(state, control)

Continuous-time dynamics \(\dot{x} = f(x, u)\).

State ordering is \([\theta_1, \theta_2, \omega_1, \omega_2]\) and control is \([\tau_1, \tau_2]\). The angular acceleration is solved from the manipulator equation \(\ddot{\theta} = M^{-1}(\tau - C\dot{\theta} - G)\).

Parameters:

Name Type Description Default
state

State vector (4,) — [theta_1, theta_2, omega_1, omega_2].

required
control

Control vector (2,) or scalar — [tau_1, tau_2].

required

Returns:

Type Description

Time derivative of the state (4,) —

[omega_1, omega_2, theta_1_ddot, theta_2_ddot].

Source code in src/shinro/plants/double_pendulum.py
def dynamics(self, state, control):
    """Continuous-time dynamics :math:`\\dot{x} = f(x, u)`.

    State ordering is :math:`[\\theta_1, \\theta_2, \\omega_1, \\omega_2]`
    and control is :math:`[\\tau_1, \\tau_2]`. The angular acceleration is
    solved from the manipulator equation
    :math:`\\ddot{\\theta} = M^{-1}(\\tau - C\\dot{\\theta} - G)`.

    Args:
        state: State vector (4,) — [theta_1, theta_2, omega_1, omega_2].
        control: Control vector (2,) or scalar — [tau_1, tau_2].

    Returns:
        Time derivative of the state (4,) —
        [omega_1, omega_2, theta_1_ddot, theta_2_ddot].
    """
    theta_1, theta_2, omega_1, omega_2 = state[0], state[1], state[2], state[3]
    diff_theta = theta_1 - theta_2
    omega = self.bk.array([omega_1, omega_2])
    M = self._make_mass_matrix(diff_theta)
    C = self._make_coriolis_matrix(diff_theta, omega)
    G = self._make_gravity_vector(self.bk.array([theta_1, theta_2]))

    tau = control if hasattr(control, '__len__') else self.bk.array([control, 0.0])
    b = tau - C @ omega - G
    thetaddot = self.bk.solve(M, b)

    return self.bk.stack([omega_1, omega_2, thetaddot[0], thetaddot[1]])

get_model

get_model(x0=None, u0=None, eps=1e-06)

Get the discrete-time state-space model around an operating point.

Linearizes the continuous-time dynamics \(f(x, u) = \dot{x}\) around (x0, u0) using central finite differences via shinro.utils.linearization.linearize_plant, then Euler-discretizes at the plant's dt. When x0/u0 are omitted, defaults to the rest equilibrium \((\theta=0, \dot{\theta}=0)\) with zero control.

Parameters:

Name Type Description Default
x0

Operating point state (4,) — [theta_1, theta_2, omega_1, omega_2]. Defaults to zeros.

None
u0

Operating point control (2,) — [tau_1, tau_2]. Defaults to zeros.

None
eps

Step size for finite differences.

1e-06

Returns:

Type Description

Tuple of (A, B) where A = I + dt·∂f/∂x is (4, 4) and

B = dt·∂f/∂u is (4, 2).

Source code in src/shinro/plants/double_pendulum.py
def get_model(self, x0=None, u0=None, eps=1e-6):
    """Get the discrete-time state-space model around an operating point.

    Linearizes the continuous-time dynamics :math:`f(x, u) = \\dot{x}`
    around ``(x0, u0)`` using central finite differences via
    :func:`shinro.utils.linearization.linearize_plant`, then
    Euler-discretizes at the plant's ``dt``. When ``x0``/``u0`` are
    omitted, defaults to the rest equilibrium
    :math:`(\\theta=0, \\dot{\\theta}=0)` with zero control.

    Args:
        x0: Operating point state (4,) —
            [theta_1, theta_2, omega_1, omega_2]. Defaults to zeros.
        u0: Operating point control (2,) — [tau_1, tau_2]. Defaults to zeros.
        eps: Step size for finite differences.

    Returns:
        Tuple of (A, B) where A = I + dt·∂f/∂x is (4, 4) and
        B = dt·∂f/∂u is (4, 2).
    """
    A_c, B_c = linearize_plant(self, x0, u0, eps=eps)
    return discretize_euler(A_c, B_c, self.dt, backend=self.bk)

get_state

get_state()

Get the current state \([\theta_1, \theta_2, \omega_1, \omega_2]\).

When a physics engine is attached, reads joint positions and velocities from the engine. Otherwise returns a copy of the internal state.

Returns:

Type Description

State vector (4,) — [theta_1, theta_2, omega_1, omega_2].

Source code in src/shinro/plants/double_pendulum.py
def get_state(self):
    """Get the current state :math:`[\\theta_1, \\theta_2, \\omega_1, \\omega_2]`.

    When a physics engine is attached, reads joint positions and
    velocities from the engine. Otherwise returns a copy of the
    internal state.

    Returns:
        State vector (4,) — [theta_1, theta_2, omega_1, omega_2].
    """
    if self._engine is not None:
        qpos_1 = self._engine.get_joint_qpos("hinge_1")
        qpos_2 = self._engine.get_joint_qpos("hinge_2")
        qvel_1 = self._engine.get_joint_vel("hinge_1")
        qvel_2 = self._engine.get_joint_vel("hinge_2")
        return self.bk.array([qpos_1, qpos_2, qvel_1, qvel_2])
    return self.bk.copy(self.state)

step

step(u)

Execute one control step.

When a physics engine is attached, sets the torque actuators and advances the engine. Otherwise integrates the analytical dynamics using semi-implicit Euler (velocities updated before positions).

Parameters:

Name Type Description Default
u

Control input (2,) or scalar — [tau_1, tau_2] joint torques (Nm).

required

Returns:

Type Description

New state vector (4,) — [theta_1, theta_2, omega_1, omega_2].

Source code in src/shinro/plants/double_pendulum.py
def step(self, u):
    """Execute one control step.

    When a physics engine is attached, sets the torque actuators and
    advances the engine. Otherwise integrates the analytical dynamics
    using semi-implicit Euler (velocities updated before positions).

    Args:
        u: Control input (2,) or scalar — [tau_1, tau_2] joint torques (Nm).

    Returns:
        New state vector (4,) — [theta_1, theta_2, omega_1, omega_2].
    """
    if self._engine is not None:
        self._engine.set_joint_ctrl("torque_1", u[0] if hasattr(u, '__len__') else u)
        self._engine.set_joint_ctrl("torque_2", u[1] if hasattr(u, '__len__') else 0.0)
        self._engine.step()
        self.state = self.get_state()
        return self.state

    xdot = self.dynamics(self.state, u)
    omega_1_new = self.state[2] + xdot[2] * self.dt
    omega_2_new = self.state[3] + xdot[3] * self.dt
    theta_1_new = self.state[0] + omega_1_new * self.dt
    theta_2_new = self.state[1] + omega_2_new * self.dt
    self.state = self.bk.array([theta_1_new, theta_2_new, omega_1_new, omega_2_new])
    if self.state_bounds is not None:
        self.state = self.bk.clip(self.state, self.state_bounds[0], self.state_bounds[1])
    return self.state

from_config classmethod

from_config(config, backend: ArrayBackend | None = None)

Create a DoublePendulum from a TOML config dict or DoublePendulumConfig.

Config fields

mass_top: Top bob mass (kg). mass_bottom: Bottom bob mass (kg). length_top: Top rod length (m). length_bottom: Bottom rod length (m). dt: Time step. g: Gravitational acceleration (m/s^2). state_bounds: Optional dict with min and max lists.

Parameters:

Name Type Description Default
config

TOML config dict (may carry a runtime-injected engine) or DoublePendulumConfig.

required
backend ArrayBackend | None

Array backend. Defaults to NumpyBackend.

None

Returns:

Type Description

DoublePendulum instance.

Source code in src/shinro/plants/double_pendulum.py
@classmethod
def from_config(cls, config, backend: ArrayBackend | None = None):
    """Create a DoublePendulum from a TOML config dict or :class:`DoublePendulumConfig`.

    Config fields:
        mass_top: Top bob mass (kg).
        mass_bottom: Bottom bob mass (kg).
        length_top: Top rod length (m).
        length_bottom: Bottom rod length (m).
        dt: Time step.
        g: Gravitational acceleration (m/s^2).
        state_bounds: Optional dict with ``min`` and ``max`` lists.

    Args:
        config: TOML config dict (may carry a runtime-injected ``engine``)
            or DoublePendulumConfig.
        backend: Array backend. Defaults to NumpyBackend.

    Returns:
        DoublePendulum instance.
    """
    bk = backend or NumpyBackend()
    clean, runtime = (
        strip_runtime_keys(config, ("engine", "joint_groups")) if isinstance(config, dict) else (config, {})
    )
    cfg = cls.parse_config(clean)
    state_bounds = None
    if cfg.state_bounds is not None:
        sb = strict_from_dict(BoundsConfig, cfg.state_bounds, "DoublePendulum.state_bounds")
        state_bounds = (
            bk.array(sb.min if sb.min is not None else [-3.14, -3.14, -10.0, -10.0]),
            bk.array(sb.max if sb.max is not None else [3.14, 3.14, 10.0, 10.0]),
        )
    plant = cls(
        mass_top=cfg.mass_top,
        mass_bottom=cfg.mass_bottom,
        length_top=cfg.length_top,
        length_bottom=cfg.length_bottom,
        dt=cfg.dt,
        g=cfg.g,
        state_bounds=state_bounds,
        backend=bk,
    )
    engine = runtime.get("engine")
    if engine is not None:
        plant.physics_engine(engine)
    return plant

Quadrotor

Bases: Plant

Quadrotor — follows HolonomicMobileRobot pattern.

State: 12D (pose + twist) Control: 4D (thrust + body torques) — higher-level abstraction TBD

TODO: implement standalone dynamics + MuJoCo engine mode

Source code in src/shinro/plants/quadrotor.py
def __init__(self, *args, **kwargs):
    raise NotImplementedError("Quadrotor plant is a placeholder — not yet implemented")