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 with 4 rotor-speed inputs


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.

joint_groups + engine are runtime-injected by RobotSim. When both are absent the arm is built in model-only standalone mode (synthetic joint names, ±π limits) — enough for the compile pipeline to derive n_x/n_u from a plant-authoritative [plant] section without a live sim.

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.

    ``joint_groups`` + ``engine`` are runtime-injected by ``RobotSim``. When
    both are absent the arm is built in model-only standalone mode
    (synthetic joint names, ±π limits) — enough for the compile pipeline to
    derive ``n_x``/``n_u`` from a plant-authoritative ``[plant]`` section
    without a live sim.

    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 not None and joint_groups is not None:
        joint_names = joint_groups[cfg.joint_group]
        limits = np.array([engine.get_joint_limits(n) for n in joint_names])
    else:
        # Model-only standalone mode. ``engine`` + ``joint_groups`` are
        # runtime-injected by RobotSim, but the plant's dimensions do not
        # need them (``get_state`` -> a 6-D end-effector pose, ``get_model``
        # -> A = I, B = dt*I). The compile pipeline uses this to derive
        # n_x/n_u from a plant-authoritative [plant] section with no live sim.
        joint_names = [f"joint_{i}" for i in range(cfg.num_dof)]
        limits = np.tile(np.array([-np.pi, np.pi]), (cfg.num_dof, 1))
    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,
    )
    if engine is not None:
        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, bk=None)

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

Parameters:

Name Type Description Default
state

State vector (2,) — [theta, theta_dot] — or a batch (N, 2).

required
control

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

required
bk

Backend to evaluate with. Defaults to the plant's backend.

None

Returns:

Type Description

Time derivative with the rank of state —

[theta_dot, theta_ddot].

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

    Args:
        state: State vector (2,) — [theta, theta_dot] — or a batch (N, 2).
        control: Control (1,), batch (N, 1), or scalar — [tau].
        bk: Backend to evaluate with. Defaults to the plant's backend.

    Returns:
        Time derivative with the rank of ``state`` —
        [theta_dot, theta_ddot].
    """
    bk = self.bk if bk is None else bk
    x, single = as_batch(bk, state)
    u = control_batch(bk, control, 1)
    theta = column(bk, x, 0)
    theta_dot = column(bk, x, 1)
    tau = column(bk, u, 0)
    theta_ddot = (
        (self.g / self.l) * bk.sin(theta)
        + tau / (self.m * self.l**2)
        - (self.b / (self.m * self.l**2)) * theta_dot
    )
    f = bk.stack([bk.ravel(theta_dot), bk.ravel(theta_ddot)]).T
    return as_vector(bk, f, single)

measurement

measurement(state, bk=None)

Bob position [l sin θ, l cos θ] relative to the pivot — a nonlinear sensor.

Measuring where the pendulum is rather than the angle directly: no linear C can express this, so it is the model a config/scenario-driven EKF/UKF gets from the plant. Follows the shinro.components.Plant.measurement contract: bk-aware and batch-capable.

Parameters:

Name Type Description Default
state

State vector (2,) — [theta, theta_dot] — or a batch (N, 2).

required
bk

Backend to evaluate with. Defaults to the plant's backend.

None

Returns:

Type Description

Bob position (2,) or batch (N, 2).

Source code in src/shinro/plants/inverted_pendulum.py
def measurement(self, state, bk=None):
    """Bob position ``[l sin θ, l cos θ]`` relative to the pivot — a nonlinear sensor.

    Measuring where the pendulum *is* rather than the angle directly: no
    linear ``C`` can express this, so it is the model a config/scenario-driven
    EKF/UKF gets from the plant. Follows the
    :meth:`~shinro.components.Plant.measurement` contract: ``bk``-aware and
    batch-capable.

    Args:
        state: State vector (2,) — [theta, theta_dot] — or a batch (N, 2).
        bk: Backend to evaluate with. Defaults to the plant's backend.

    Returns:
        Bob position ``(2,)`` or batch ``(N, 2)``.
    """
    bk = self.bk if bk is None else bk
    x, single = as_batch(bk, state)
    theta = column(bk, x, 0)
    y = bk.stack([bk.ravel(self.l * bk.sin(theta)), bk.ravel(self.l * bk.cos(theta))]).T
    return as_vector(bk, y, single)

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

    f = self.dynamics(self.state, u)
    theta_dot_new = self.state[1] + f[1] * self.dt
    theta_new = self.state[0] + 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, bk=None)

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

Parameters:

Name Type Description Default
state

State vector (4,) — [x, x_dot, theta, theta_dot] — or a batch (N, 4).

required
control

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

required
bk

Backend to evaluate with. Defaults to the plant's backend.

None

Returns:

Type Description

Time derivative with the rank of state —

[x_dot, x_ddot, theta_dot, theta_ddot].

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

    Args:
        state: State vector (4,) — [x, x_dot, theta, theta_dot] — or a
            batch (N, 4).
        control: Control (1,), batch (N, 1), or scalar — [F].
        bk: Backend to evaluate with. Defaults to the plant's backend.

    Returns:
        Time derivative with the rank of ``state`` —
        [x_dot, x_ddot, theta_dot, theta_ddot].
    """
    bk = self.bk if bk is None else bk
    x, single = as_batch(bk, state)
    u = control_batch(bk, control, 1)
    x_pos = column(bk, x, 0)
    x_dot = column(bk, x, 1)
    theta = column(bk, x, 2)
    theta_dot = column(bk, x, 3)
    x_ddot, theta_ddot = self._compute_accels(x_pos, theta, x_dot, theta_dot, column(bk, u, 0), bk=bk)
    f = bk.stack([bk.ravel(x_dot), bk.ravel(x_ddot), bk.ravel(theta_dot), bk.ravel(theta_ddot)]).T
    return as_vector(bk, f, single)

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

    f = self.dynamics(self.state, u)
    theta_dot_new = self.state[3] + f[3] * self.dt
    x_dot_new = self.state[1] + f[1] * self.dt
    theta_new = self.state[2] + theta_dot_new * self.dt
    x_new = self.state[0] + 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, bk=None)

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

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

\[ M = \begin{bmatrix} (m_1 + m_2) l_1^2 & m_2 l_1 l_2 \cos\Delta \ m_2 l_1 l_2 \cos\Delta & m_2 l_2^2 \end{bmatrix}, \quad \Delta = \theta_1 - \theta_2, \]

\(C\dot{\theta} = [m_2 l_1 l_2 \sin\Delta\,\omega_2^2, -m_2 l_1 l_2 \sin\Delta\,\omega_1^2]\), and \(G = [(m_1 + m_2) g l_1 \sin\theta_1, m_2 g l_2 \sin\theta_2]\).

The 2x2 solve is written in closed form (Cramer's rule) rather than bk.solve: on the batched path M would be rank-3 ((N, 2, 2)), which the graph backend — strictly 2-D — does not represent. The entries are therefore (N, 1) columns, and the determinant \(m_2 l_1^2 l_2^2 (m_1 + m_2 \sin^2\Delta)\) is strictly positive for positive masses, so the division is always safe.

Parameters:

Name Type Description Default
state

State (4,) — [theta_1, theta_2, omega_1, omega_2] — or a batch (N, 4).

required
control

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

required
bk

Backend to evaluate with. Defaults to the plant's backend.

None

Returns:

Type Description

Time derivative with the rank of state —

[omega_1, omega_2, theta_1_ddot, theta_2_ddot].

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

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

    .. math::

        M = \\begin{bmatrix} (m_1 + m_2) l_1^2 & m_2 l_1 l_2 \\cos\\Delta \\
        m_2 l_1 l_2 \\cos\\Delta & m_2 l_2^2 \\end{bmatrix},
        \\quad \\Delta = \\theta_1 - \\theta_2,

    :math:`C\\dot{\\theta} = [m_2 l_1 l_2 \\sin\\Delta\\,\\omega_2^2,
    -m_2 l_1 l_2 \\sin\\Delta\\,\\omega_1^2]`, and
    :math:`G = [(m_1 + m_2) g l_1 \\sin\\theta_1,
    m_2 g l_2 \\sin\\theta_2]`.

    The 2x2 solve is written in closed form (Cramer's rule) rather than
    ``bk.solve``: on the batched path ``M`` would be rank-3 (``(N, 2, 2)``),
    which the graph backend — strictly 2-D — does not represent. The
    entries are therefore ``(N, 1)`` columns, and the determinant
    :math:`m_2 l_1^2 l_2^2 (m_1 + m_2 \\sin^2\\Delta)` is strictly
    positive for positive masses, so the division is always safe.

    Args:
        state: State (4,) — [theta_1, theta_2, omega_1, omega_2] — or a
            batch (N, 4).
        control: Control (2,), batch (N, 2), or scalar — [tau_1, tau_2].
        bk: Backend to evaluate with. Defaults to the plant's backend.

    Returns:
        Time derivative with the rank of ``state`` —
        [omega_1, omega_2, theta_1_ddot, theta_2_ddot].
    """
    bk = self.bk if bk is None else bk
    x, single = as_batch(bk, state)
    u = control_batch(bk, control, 2)
    theta_1 = column(bk, x, 0)
    theta_2 = column(bk, x, 1)
    omega_1 = column(bk, x, 2)
    omega_2 = column(bk, x, 3)
    tau_1 = column(bk, u, 0)
    tau_2 = column(bk, u, 1)

    diff = theta_1 - theta_2
    sin_diff = bk.sin(diff)
    # Mass matrix entries; only m12 is state-dependent.
    m11 = (self.m1 + self.m2) * self.l1**2
    m12 = self.m2 * self.l1 * self.l2 * bk.cos(diff)
    m22 = self.m2 * self.l2**2
    # b = tau - C omega - G, one (N, 1) column per joint.
    b1 = (
        tau_1
        - self.m2 * self.l1 * self.l2 * sin_diff * omega_2 * omega_2
        - (self.m1 + self.m2) * self.g * self.l1 * bk.sin(theta_1)
    )
    b2 = (
        tau_2
        + self.m2 * self.l1 * self.l2 * sin_diff * omega_1 * omega_1
        - self.m2 * self.g * self.l2 * bk.sin(theta_2)
    )
    # theta_ddot = M^-1 b, 2x2 closed form.
    det = m11 * m22 - m12 * m12
    theta_1_ddot = (b1 * m22 - m12 * b2) / det
    theta_2_ddot = (m11 * b2 - b1 * m12) / det

    f = bk.stack([
        bk.ravel(omega_1),
        bk.ravel(omega_2),
        bk.ravel(theta_1_ddot),
        bk.ravel(theta_2_ddot),
    ]).T
    return as_vector(bk, f, single)

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

12-state quadrotor with four rotor-speed inputs.

The state is \([x, y, z, \phi, \theta, \psi, \dot{x}, \dot{y}, \dot{z}, p, q, r]\) — position, Euler angles (roll/pitch/yaw), world-frame linear velocity, and body-frame angular velocity. The control is the four rotor angular speeds \([\omega_1, \omega_2, \omega_3, \omega_4]\).

Rotor placement is a configurable mixer. Each rotor sits at body \((x_i, y_i)\) and has a spin sign \(\sigma_i\) (\(+1\) counter-clockwise seen from above, \(-1\) clockwise). Rotor \(i\) contributes thrust \(k\omega_i^2\) along body \(+z\), so its moment about the body axes is \(k\omega_i^2 (y_i, -x_i, 0)\), and its yaw reaction is \(-b\sigma_i\omega_i^2\) about \(z\):

\[ F = k \sum_i \omega_i^2, \quad \tau_x = k \sum_i y_i \omega_i^2, \quad \tau_y = -k \sum_i x_i \omega_i^2, \quad \tau_z = -b \sum_i \sigma_i \omega_i^2. \]

The default mixer (no rotors config) is the + layout \([(\ell,0), (0,\ell), (-\ell,0), (0,-\ell)]\) with spins \([+,-,+,-]\), which gives the familiar \(\tau_x = k\ell(\omega_2^2 - \omega_4^2)\), \(\tau_y = k\ell(\omega_3^2 - \omega_1^2)\), \(\tau_z = b(\omega_2^2 + \omega_4^2 - \omega_1^2 - \omega_3^2)\). A different numbering/placement is just a different table; see RotorConfig and samples/plants/quadrotor.toml.

The translational dynamics apply the body thrust along body \(+z\) through the rotation matrix \(R = R_z(\psi) R_y(\theta) R_x(\phi)\) and cancel gravity:

\[ \ddot{\mathbf{p}} = \frac{1}{m} R \begin{bmatrix}0\\0\\F\end{bmatrix} - \begin{bmatrix}0\\0\\g\end{bmatrix}. \]

The rotational dynamics are Euler's rigid-body equations with the diagonal inertia \(I = \mathrm{diag}(I_{xx}, I_{yy}, I_{zz})\),

\[ \dot{\boldsymbol{\omega}} = I^{-1} \left(\boldsymbol{\tau} - \boldsymbol{\omega} \times I\boldsymbol{\omega}\right), \]

and the Euler angles evolve through the body-rate kinematics \(\dot{\boldsymbol{\eta}} = T(\phi, \theta)\,\boldsymbol{\omega}\).

Supports standalone analytical integration (explicit-Euler step) and dynamics for finite-difference linearization and batched MPPI rollouts. MuJoCo engine mode is not yet implemented: attaching an engine only wires the backend, and step refuses to run while one is attached.

Parameters:

Name Type Description Default
mass float

Total mass (kg).

0.5
radius float

Rotor-to-center arm length \(\ell\) (m).

0.1
inertia list[float] | None

Body-frame principal moments [I_xx, I_yy, I_zz] (kg·m²). Defaults to [0.005, 0.005, 0.01].

None
dt float

Integration step (s).

0.01
g float

Gravitational acceleration (m/s²).

9.81
thrust_coeff float

Rotor thrust coefficient \(k\) (N·s²/rad²).

1.0
torque_coeff float

Rotor drag/yaw coefficient \(b\) (N·m·s²/rad²).

0.1
rotors list[tuple[float, float, float]] | None

Optional mixer table of (x, y, spin) tuples, one per rotor in control order. None selects the default + layout built from radius.

None
state_bounds tuple | None

Optional (min, max) arrays of shape (12,) clipping the state after each step.

None
backend ArrayBackend | None

Array backend. Defaults to NumpyBackend.

None
Source code in src/shinro/plants/quadrotor.py
def __init__(
    self,
    mass: float = 0.5,
    radius: float = 0.1,
    inertia: list[float] | None = None,
    dt: float = 0.01,
    g: float = 9.81,
    thrust_coeff: float = 1.0,
    torque_coeff: float = 0.1,
    rotors: list[tuple[float, float, float]] | None = None,
    state_bounds: tuple | None = None,
    backend: ArrayBackend | None = None,
):
    if mass <= 0:
        raise ValueError("Quadrotor mass must be positive.")
    if dt <= 0:
        raise ValueError("dt must be positive.")
    inertia = [0.005, 0.005, 0.01] if inertia is None else list(inertia)
    if len(inertia) != 3 or any(i <= 0 for i in inertia):
        raise ValueError("inertia must be three positive principal moments [I_xx, I_yy, I_zz].")
    if rotors is None:
        if radius <= 0:
            raise ValueError("Quadrotor radius (rotor arm) must be positive.")
        table: tuple[tuple[float, float, float], ...] = (
            (radius, 0.0, 1.0),
            (0.0, radius, -1.0),
            (-radius, 0.0, 1.0),
            (0.0, -radius, -1.0),
        )
    else:
        table = tuple(rotors)
    if len(table) != 4:
        raise ValueError("Quadrotor requires exactly four rotors in the mixer table.")

    self.bk = backend or NumpyBackend()
    self.m = mass
    self.r = radius
    self.I = inertia
    self.dt = dt
    self.g = g
    self.k = thrust_coeff
    self.b = torque_coeff
    self.rotors = table
    self.input_dim = len(table)
    self.state_bounds = state_bounds
    self.state = self.bk.zeros(12)
    self._engine = None

physics_engine

physics_engine(engine: PhysicsEngine | None)

Attach or detach a physics engine.

Engine-backed simulation is not yet implemented for the quadrotor; this only stores the engine and inherits its backend so a future MJCF mapping can reuse the same wiring. step refuses to run while an engine is attached, so the analytic model is never silently bypassed.

Parameters:

Name Type Description Default
engine PhysicsEngine | None

PhysicsEngine instance or None to detach.

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

    Engine-backed simulation is not yet implemented for the quadrotor; this
    only stores the engine and inherits its backend so a future MJCF mapping
    can reuse the same wiring. ``step`` refuses to run while an engine is
    attached, so the analytic model is never silently bypassed.

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

dynamics

dynamics(state, control, bk=None)

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

State ordering is \([x, y, z, \phi, \theta, \psi, \dot{x}, \dot{y}, \dot{z}, p, q, r]\) and control is the four rotor speeds \([\omega_1, \omega_2, \omega_3, \omega_4]\). The returned derivative is \([\dot{x}, \dot{y}, \dot{z}, \dot{\phi}, \dot{\theta}, \dot{\psi}, \ddot{x}, \ddot{y}, \ddot{z}, \dot{p}, \dot{q}, \dot{r}]\).

The rotation and Euler-rate terms are expanded into their closed-form components rather than applied as 3x3 matrix products: on the batched path the matrices would be rank-3 (N, 3, 3), which the graph backend (strictly 2-D) does not represent.

Parameters:

Name Type Description Default
state

State (12,) — [position, Euler angles, world velocity, body rates] — or a batch (N, 12).

required
control

Control (4,), batch (N, 4), or scalar — rotor angular speeds.

required
bk

Backend to evaluate with. Defaults to the plant's backend.

None

Returns:

Type Description

Time derivative with the rank of state, (12,) or (N, 12).

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

    State ordering is :math:`[x, y, z, \\phi, \\theta, \\psi, \\dot{x},
    \\dot{y}, \\dot{z}, p, q, r]` and control is the four rotor speeds
    :math:`[\\omega_1, \\omega_2, \\omega_3, \\omega_4]`. The returned
    derivative is :math:`[\\dot{x}, \\dot{y}, \\dot{z}, \\dot{\\phi},
    \\dot{\\theta}, \\dot{\\psi}, \\ddot{x}, \\ddot{y}, \\ddot{z},
    \\dot{p}, \\dot{q}, \\dot{r}]`.

    The rotation and Euler-rate terms are expanded into their closed-form
    components rather than applied as 3x3 matrix products: on the batched
    path the matrices would be rank-3 ``(N, 3, 3)``, which the graph backend
    (strictly 2-D) does not represent.

    Args:
        state: State (12,) — [position, Euler angles, world velocity, body
            rates] — or a batch (N, 12).
        control: Control (4,), batch (N, 4), or scalar — rotor angular
            speeds.
        bk: Backend to evaluate with. Defaults to the plant's backend.

    Returns:
        Time derivative with the rank of ``state``, (12,) or (N, 12).
    """
    bk = self.bk if bk is None else bk
    x, single = as_batch(bk, state)
    u = control_batch(bk, control, 4)

    roll = column(bk, x, 3)
    pitch = column(bk, x, 4)
    yaw = column(bk, x, 5)
    vx = column(bk, x, 6)
    vy = column(bk, x, 7)
    vz = column(bk, x, 8)
    p = column(bk, x, 9)
    q = column(bk, x, 10)
    r = column(bk, x, 11)

    # Rotor mixer (configurable table) -> collective thrust + body torques.
    # Rotor i at body (x_i, y_i) with spin sigma_i contributes thrust
    # k*w_i^2 along +z: moment k*w_i^2*(y_i, -x_i, 0) and yaw reaction
    # -b*sigma_i*w_i^2. Python-level unroll over the (static) table keeps
    # this trace-safe with a fixed graph cost.
    thrust = 0.0
    tau_roll = 0.0
    tau_pitch = 0.0
    tau_yaw = 0.0
    for i, (rx, ry, spin) in enumerate(self.rotors):
        wi = column(bk, u, i)
        sq = wi * wi
        thrust = thrust + self.k * sq
        tau_roll = tau_roll + self.k * ry * sq
        tau_pitch = tau_pitch - self.k * rx * sq
        tau_yaw = tau_yaw - self.b * spin * sq

    # Euler's rigid-body equations, diagonal inertia.
    i_xx, i_yy, i_zz = self.I[0], self.I[1], self.I[2]
    p_dot = (tau_roll + q * r * (i_yy - i_zz)) / i_xx
    q_dot = (tau_pitch + r * p * (i_zz - i_xx)) / i_yy
    r_dot = (tau_yaw + p * q * (i_xx - i_yy)) / i_zz

    # Euler-angle kinematics eta_dot = T(roll, pitch) @ [p, q, r].
    sin_r, cos_r = bk.sin(roll), bk.cos(roll)
    sin_p, cos_p = bk.sin(pitch), bk.cos(pitch)
    sin_y, cos_y = bk.sin(yaw), bk.cos(yaw)
    tan_p = sin_p / cos_p
    roll_dot = p + q * sin_r * tan_p + r * cos_r * tan_p
    pitch_dot = q * cos_r - r * sin_r
    # Divide by cos(pitch) directly rather than multiplying by a reciprocal:
    # Tracer has no reflected division, so ``1.0 / cos_p`` would not trace.
    yaw_dot = (q * sin_r + r * cos_r) / cos_p

    # World-frame acceleration: R @ [0, 0, F] / m - [0, 0, g].
    accel_x = thrust * (sin_p * cos_r * cos_y + sin_r * sin_y) / self.m
    accel_y = thrust * (sin_p * cos_r * sin_y - sin_r * cos_y) / self.m
    accel_z = thrust * (cos_p * cos_r) / self.m - self.g

    f = bk.stack([
        bk.ravel(vx),
        bk.ravel(vy),
        bk.ravel(vz),
        bk.ravel(roll_dot),
        bk.ravel(pitch_dot),
        bk.ravel(yaw_dot),
        bk.ravel(accel_x),
        bk.ravel(accel_y),
        bk.ravel(accel_z),
        bk.ravel(p_dot),
        bk.ravel(q_dot),
        bk.ravel(r_dot),
    ]).T
    return as_vector(bk, f, single)

get_state

get_state()

Get the current state.

Returns:

Type Description

State vector (12,) — [x, y, z, roll, pitch, yaw, vx, vy, vz,

p, q, r].

Source code in src/shinro/plants/quadrotor.py
def get_state(self):
    """Get the current state.

    Returns:
        State vector (12,) — [x, y, z, roll, pitch, yaw, vx, vy, vz,
        p, q, r].
    """
    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 is omitted it defaults to the level, zero-velocity state; when u0 is omitted it defaults to the rotor speed that balances gravity, \(\omega_{\text{hover}} = \sqrt{mg/(4k)}\), so the model is linearized about the true hover equilibrium (u0 = 0 would give B = 0 because the rotor forces are quadratic in speed).

Parameters:

Name Type Description Default
x0

Operating point state (12,). Defaults to zeros (level attitude, zero velocity).

None
u0

Operating point control (4,) — rotor speeds. Defaults to the hover rotor speed \(\sqrt{mg/(4k)}\) on all four rotors.

None
eps

Step size for finite differences.

1e-06

Returns:

Type Description

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

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

Source code in src/shinro/plants/quadrotor.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`` is omitted it
    defaults to the level, zero-velocity state; when ``u0`` is omitted it
    defaults to the rotor speed that balances gravity,
    :math:`\\omega_{\\text{hover}} = \\sqrt{mg/(4k)}`, so the model is
    linearized about the true hover equilibrium (``u0 = 0`` would give
    ``B = 0`` because the rotor forces are quadratic in speed).

    Args:
        x0: Operating point state (12,).
            Defaults to zeros (level attitude, zero velocity).
        u0: Operating point control (4,) — rotor speeds. Defaults to the
            hover rotor speed :math:`\\sqrt{mg/(4k)}` on all four rotors.
        eps: Step size for finite differences.

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

operating_control

operating_control()

Hover rotor speeds — the equilibrium the control matrix is evaluated at.

The rotors' thrust is quadratic in speed, so g(x, 0) = 0; the tangent model (shinro.components.Plant.model_terms) must be taken about the hover equilibrium \(\omega_{\text{hover}} = \sqrt{mg/(4k)}\) rather than zero.

Returns:

Type Description

Hover rotor speeds (4,).

Source code in src/shinro/plants/quadrotor.py
def operating_control(self):
    """Hover rotor speeds — the equilibrium the control matrix is evaluated at.

    The rotors' thrust is quadratic in speed, so ``g(x, 0) = 0``; the
    tangent model (:meth:`~shinro.components.Plant.model_terms`) must be
    taken about the hover equilibrium
    :math:`\\omega_{\\text{hover}} = \\sqrt{mg/(4k)}` rather than zero.

    Returns:
        Hover rotor speeds (4,).
    """
    w_hover = (self.m * self.g / (4.0 * self.k)) ** 0.5
    return self.bk.array([w_hover, w_hover, w_hover, w_hover])

step

step(u)

Execute one control step with explicit Euler integration.

Velocities (world linear + body angular) are updated first, then positions and Euler angles are advanced with the updated velocities.

Parameters:

Name Type Description Default
u

Control input (4,) or scalar — rotor angular speeds.

required

Returns:

Type Description

New state vector (12,).

Raises:

Type Description
NotImplementedError

If a physics engine is attached (MuJoCo engine mode is not implemented yet).

Source code in src/shinro/plants/quadrotor.py
def step(self, u):
    """Execute one control step with explicit Euler integration.

    Velocities (world linear + body angular) are updated first, then
    positions and Euler angles are advanced with the updated velocities.

    Args:
        u: Control input (4,) or scalar — rotor angular speeds.

    Returns:
        New state vector (12,).

    Raises:
        NotImplementedError: If a physics engine is attached (MuJoCo engine
            mode is not implemented yet).
    """
    if self._engine is not None:
        raise NotImplementedError(
            "Quadrotor MuJoCo engine mode is not implemented; detach the engine "
            "to use the analytic step."
        )

    xdot = self.dynamics(self.state, u)
    vel = self.state[6:9] + xdot[6:9] * self.dt
    rates = self.state[9:12] + xdot[9:12] * self.dt
    pos = self.state[0:3] + vel * self.dt
    euler = self.state[3:6] + xdot[3:6] * self.dt
    self.state = self.bk.hstack([pos, euler, vel, rates])
    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 Quadrotor from a TOML config dict or QuadrotorConfig.

Config fields

mass: Total mass (kg). radius: Rotor arm length (m) — used for the default + mixer. inertia: Body-frame principal moments (kg·m²). thrust_coeff: Rotor thrust coefficient. torque_coeff: Rotor drag/yaw coefficient. dt: Time step (s). g: Gravitational acceleration (m/s²). rotors: Optional [[rotors]] mixer table (x, y, spin per entry); overrides the default layout. 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 QuadrotorConfig.

required
backend ArrayBackend | None

Array backend. Defaults to NumpyBackend.

None

Returns:

Type Description

Quadrotor instance.

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

    Config fields:
        mass: Total mass (kg).
        radius: Rotor arm length (m) — used for the default ``+`` mixer.
        inertia: Body-frame principal moments (kg·m²).
        thrust_coeff: Rotor thrust coefficient.
        torque_coeff: Rotor drag/yaw coefficient.
        dt: Time step (s).
        g: Gravitational acceleration (m/s²).
        rotors: Optional ``[[rotors]]`` mixer table (``x``, ``y``, ``spin``
            per entry); overrides the default layout.
        state_bounds: Optional dict with ``min`` and ``max`` lists.

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

    Returns:
        Quadrotor 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, "Quadrotor.state_bounds")
        state_bounds = (
            bk.array(sb.min if sb.min is not None else [
                -10.0, -10.0, -10.0, -3.14, -1.57, -3.14, -10.0, -10.0, -10.0, -10.0, -10.0, -10.0,
            ]),
            bk.array(sb.max if sb.max is not None else [
                10.0, 10.0, 10.0, 3.14, 1.57, 3.14, 10.0, 10.0, 10.0, 10.0, 10.0, 10.0,
            ]),
        )
    rotors = None
    if cfg.rotors is not None:
        parsed = strict_from_list(RotorConfig, cfg.rotors, "Quadrotor.rotors")
        rotors = [(r.x, r.y, r.spin) for r in parsed]
    plant = cls(
        mass=cfg.mass,
        radius=cfg.radius,
        inertia=cfg.inertia,
        dt=cfg.dt,
        g=cfg.g,
        thrust_coeff=cfg.thrust_coeff,
        torque_coeff=cfg.torque_coeff,
        rotors=rotors,
        state_bounds=state_bounds,
        backend=bk,
    )
    engine = runtime.get("engine")
    if engine is not None:
        plant.physics_engine(engine)
    return plant