Skip to content

Controllers

shinro.controllers

Control algorithms for state regulation and trajectory tracking.

Provides discrete-time controllers that compute control actions from state feedback. All controllers implement the Controller ABC.

Available controllers: LQR — Linear Quadratic Regulator (DARE-based optimal gain) PIDController — Proportional-Integral-Derivative with anti-windup MPC_LTI — Linear Time-Invariant MPC with OSQP QP solver MPC_LTI_DeltaU — MPC with Δu (control rate) regularization MPPIController — Model Predictive Path Integral (sampling-based) SlidingModeController — Sliding Mode Control (robust nonlinear)


LQR

Bases: Controller

Linear Quadratic Regulator for discrete-time systems.

Computes the optimal state-feedback control law \(u = -K (x - x_t)\) that minimizes:

\[ J = \sum_k \left( x_k^T Q x_k + u_k^T R u_k \right) \]

The gain K is computed once via the Discrete Algebraic Riccati Equation (DARE) and applied online as a single matrix-vector multiply.

The DARE solve uses scipy (numpy-only) since there is no equivalent in PyTorch. The conversion is handled transparently via bk.to_numpy / bk.from_numpy.

Parameters:

Name Type Description Default
state_cost_matrix

Q — penalizes state deviation (n_x, n_x).

required
control_cost_matrix

R — penalizes control effort (n_u, n_u).

required
dynamics_state_matrix

A — discrete-time state transition (n_x, n_x).

required
dynamics_control_matrix

B — control input matrix (n_x, n_u).

required
backend ArrayBackend | None

Array backend. Defaults to NumpyBackend.

None
Source code in src/shinro/controllers/lqr.py
def __init__(
    self,
    state_cost_matrix,
    control_cost_matrix,
    dynamics_state_matrix,
    dynamics_control_matrix,
    backend: ArrayBackend | None = None,
):
    self.bk = backend or NumpyBackend()
    self.A = dynamics_state_matrix
    self.B = dynamics_control_matrix
    self.Q = state_cost_matrix
    self.R = control_cost_matrix
    self.gain_calculation()

gain_calculation

gain_calculation()

Solve DARE and compute the optimal LQR gain matrix K.

Solves \(P = A^T P A - A^T P B (R + B^T P B)^{-1} B^T P A + Q\) via scipy.linalg.solve_discrete_are, then computes:

\[ K = (R + B^T P B)^{-1} B^T P A \]

The gain K is stored as self.K and used in compute().

Source code in src/shinro/controllers/lqr.py
def gain_calculation(self):
    """Solve DARE and compute the optimal LQR gain matrix K.

    Solves :math:`P = A^T P A - A^T P B (R + B^T P B)^{-1} B^T P A + Q`
    via ``scipy.linalg.solve_discrete_are``, then computes:

    .. math::

        K = (R + B^T P B)^{-1} B^T P A

    The gain K is stored as ``self.K`` and used in ``compute()``.
    """
    A_np = self.bk.to_numpy(self.A)
    B_np = self.bk.to_numpy(self.B)
    P_np = solve_discrete_are(A_np, B_np, self.bk.to_numpy(self.Q), self.bk.to_numpy(self.R))
    P = self.bk.from_numpy(P_np)
    self.K = self.bk.inv(self.R + self.B.T @ P @ self.B) @ (self.B.T @ P @ self.A)

compute

compute(current_state, target_state: Any | None = None)

Compute the optimal control input \(u = -K (x - x_t)\).

Parameters:

Name Type Description Default
current_state

Current state vector (n_x,).

required
target_state Any | None

Desired state vector (n_x,). Defaults to zeros.

None

Returns:

Type Description

Control input vector (n_u,).

Source code in src/shinro/controllers/lqr.py
def compute(self, current_state, target_state: Any | None = None):
    """Compute the optimal control input :math:`u = -K (x - x_t)`.

    Args:
        current_state: Current state vector (n_x,).
        target_state: Desired state vector (n_x,). Defaults to zeros.

    Returns:
        Control input vector (n_u,).
    """
    if target_state is None:
        target_state = self.bk.zeros_like(current_state)
    error = target_state - current_state
    return self.K @ error

reset

reset()

No internal state to reset for LQR.

Source code in src/shinro/controllers/lqr.py
def reset(self):
    """No internal state to reset for LQR."""

from_config classmethod

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

Create an LQR controller from a TOML config dict or LQRConfig.

Config fields

state_cost: Diagonal Q weights (n_x,) or full Q matrix (n_x, n_x). control_cost: Diagonal R weights (n_u,) or full R matrix (n_u, n_u). dt: Time step — used to set B = dt * I unless B_dynamics is given. A_dynamics: Optional full A matrix (n_x, n_x). Defaults to I. B_dynamics: Optional full B matrix (n_x, n_u). Defaults to dt * I.

Parameters:

Name Type Description Default
config

TOML config dict or LQRConfig.

required
backend ArrayBackend | None

Array backend. Defaults to NumpyBackend.

None

Returns:

Type Description

LQR instance.

Source code in src/shinro/controllers/lqr.py
@classmethod
def from_config(cls, config, backend: ArrayBackend | None = None):
    """Create an LQR controller from a TOML config dict or :class:`LQRConfig`.

    Config fields:
        state_cost: Diagonal Q weights (n_x,) or full Q matrix (n_x, n_x).
        control_cost: Diagonal R weights (n_u,) or full R matrix (n_u, n_u).
        dt: Time step — used to set B = dt * I unless B_dynamics is given.
        A_dynamics: Optional full A matrix (n_x, n_x). Defaults to I.
        B_dynamics: Optional full B matrix (n_x, n_u). Defaults to dt * I.

    Args:
        config: TOML config dict or LQRConfig.
        backend: Array backend. Defaults to NumpyBackend.

    Returns:
        LQR instance.
    """
    bk = backend or NumpyBackend()
    cfg = cls.parse_config(config)
    Q = parse_matrix(bk, cfg.state_cost)
    n = Q.shape[0]
    A = bk.array(cfg.A_dynamics) if cfg.A_dynamics is not None else bk.eye(n)
    if cfg.B_dynamics is not None:
        B = bk.array(cfg.B_dynamics)
    elif cfg.dt is not None:
        B = cfg.dt * bk.eye(n)
    else:
        raise ValueError("LQR: no B_dynamics and no dt — standalone use requires one of them")
    return cls(
        state_cost_matrix=Q,
        control_cost_matrix=parse_matrix(bk, cfg.control_cost),
        dynamics_state_matrix=A,
        dynamics_control_matrix=B,
        backend=bk,
    )

PIDController

Bases: Controller

Proportional-Integral-Derivative controller with anti-windup.

Computes control effort as:

\[ u(t) = K_p e(t) + K_i \int e(\tau) d\tau + K_d \frac{de}{dt} \]

Features: - Independent gains per channel (Kp, Ki, Kd as vectors). - Output clamping with integral anti-windup back-calculation. - Derivative on error (standard form).

When output is clamped, the integral term is back-calculated on saturated channels only to prevent integral windup.

Parameters:

Name Type Description Default
kp

Proportional gain vector (n,).

required
ki

Integral gain vector (n,).

required
kd

Derivative gain vector (n,).

required
dt float

Time step in seconds.

required
output_limits tuple | None

Optional (min_limits, max_limits) for output clamping. Each is an array of shape (n,).

None
backend ArrayBackend | None

Array backend. Defaults to NumpyBackend.

None
Source code in src/shinro/controllers/pid.py
def __init__(
    self,
    kp,
    ki,
    kd,
    dt: float,
    output_limits: tuple | None = None,
    backend: ArrayBackend | None = None,
):
    self.bk = backend or NumpyBackend()
    self.kp = kp
    self.kd = kd
    self.ki = ki
    self.dt = dt

    self.min_limits = output_limits[0] if output_limits else None
    self.max_limits = output_limits[1] if output_limits else None
    self._integral = self.bk.zeros_like(self.ki)
    self._prev_error = self.bk.zeros_like(self.kd)
    # First-tick gate as a 0/1 array (not a Python bool): under tracing a
    # bool branch would bake the first-tick path into the graph forever.
    # As a recurrent state port the graph gates the D-term at runtime.
    self._has_run = self.bk.zeros_like(self.ki)

compute

compute(current_state, target_state)

Compute the PID control effort.

Parameters:

Name Type Description Default
current_state

Measured current state (n,).

required
target_state

Desired target state (n,).

required

Returns:

Type Description

Control effort vector (n,).

Source code in src/shinro/controllers/pid.py
def compute(self, current_state, target_state):
    """Compute the PID control effort.

    Args:
        current_state: Measured current state (n,).
        target_state: Desired target state (n,).

    Returns:
        Control effort vector (n,).
    """
    error = target_state - current_state
    p_term = self.kp * error
    self._integral = self._integral + error * self.dt
    i_term = self.ki * self._integral

    # Where-gate: der = has_run ? (e - e_prev)/dt : 0. A Python branch
    # would bake the first-tick path into the traced graph forever; the
    # 0/1 flag is a recurrent state port the host feeds back (0 on tick
    # 0, 1 afterwards). where (not multiply): on tick 0 the candidate
    # divides by dt — with dt=0 that's inf, and inf*0 = NaN under a
    # multiply gate, while where simply discards the unselected branch.
    # The condition is an explicit `!= 0` so torch gets a boolean tensor
    # (torch.where rejects float conditions); under tracing it emits the
    # same ne node.
    der = self.bk.where(
        self._has_run != 0,
        (error - self._prev_error) / self.dt,
        self.bk.zeros_like(error),
    )
    d_term = self.kd * der

    control_effort = p_term + i_term + d_term

    if self.min_limits is not None and self.max_limits is not None:
        clamped_effort = self.bk.clip(control_effort, self.min_limits, self.max_limits)
        # Branch-free anti-windup: an elementwise mask replaces the old
        # `if bk.any(saturated)` early-out (a Python branch on a traced
        # value, which would silently apply the back-calculation every
        # tick). Where a channel is unsaturated the mask is 0 and the
        # integral passes through unchanged — identical results.
        saturated = control_effort != clamped_effort
        self._integral = self.bk.where(
            saturated,
            self._integral - error * self.dt,
            self._integral,
        )
        control_effort = clamped_effort

    self._prev_error = self.bk.copy(error)
    self._has_run = self.bk.zeros_like(self.ki) + 1.0
    return control_effort

reset

reset()

Reset the controller's internal state (integral and previous error).

Source code in src/shinro/controllers/pid.py
def reset(self):
    """Reset the controller's internal state (integral and previous error)."""
    self._integral = self.bk.zeros_like(self.ki)
    self._prev_error = self.bk.zeros_like(self.kd)
    self._has_run = self.bk.zeros_like(self.ki)

from_config classmethod

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

Create a PID controller from a TOML config dict or PIDConfig.

Config fields

kp: List of proportional gains (n,). Defaults to [1.0]. ki: List of integral gains (n,). Defaults to zeros. kd: List of derivative gains (n,). Defaults to zeros. dt: Time step. Required at runtime; injected from the plant in scenario builds. output_limits: Optional dict with min and max lists.

Parameters:

Name Type Description Default
config

TOML config dict or PIDConfig.

required
backend ArrayBackend | None

Array backend. Defaults to NumpyBackend.

None

Returns:

Type Description

PIDController instance.

Source code in src/shinro/controllers/pid.py
@classmethod
def from_config(cls, config, backend: ArrayBackend | None = None):
    """Create a PID controller from a TOML config dict or :class:`PIDConfig`.

    Config fields:
        kp: List of proportional gains (n,). Defaults to [1.0].
        ki: List of integral gains (n,). Defaults to zeros.
        kd: List of derivative gains (n,). Defaults to zeros.
        dt: Time step. Required at runtime; injected from the plant in
            scenario builds.
        output_limits: Optional dict with ``min`` and ``max`` lists.

    Args:
        config: TOML config dict or PIDConfig.
        backend: Array backend. Defaults to NumpyBackend.

    Returns:
        PIDController instance.
    """
    bk = backend or NumpyBackend()
    cfg = cls.parse_config(config)
    if cfg.dt is None:
        raise ValueError(
            "PID: dt is required (runtime integration) — omit it only in scenario "
            "builds, where the plant's dt is injected"
        )
    n = len(cfg.kp) if cfg.kp is not None else 1
    limits = None
    if cfg.output_limits is not None:
        lim = strict_from_dict(LimitsConfig, cfg.output_limits, "PID.output_limits")
        limits = (bk.array(lim.min), bk.array(lim.max))
    return cls(
        kp=bk.array(cfg.kp if cfg.kp is not None else [1.0] * n),
        ki=bk.array(cfg.ki if cfg.ki is not None else [0.0] * n),
        kd=bk.array(cfg.kd if cfg.kd is not None else [0.0] * n),
        dt=cfg.dt,
        output_limits=limits,
        backend=bk,
    )

MPC_LTI_DeltaU

Bases: MPC_LTI

MPC with \(\Delta u\) (control rate) regularization.

Augments the state to \([x; u_{\text{prev}}]\) so the control variable becomes \(\Delta u = u_k - u_{k-1}\). The cost penalizes \(\Delta u^T S \Delta u\), smoothing chatter.

The augmented dynamics are:

\[ z_{k+1} = \begin{bmatrix} A & B \\ 0 & I \end{bmatrix} z_k + \begin{bmatrix} B \\ I \end{bmatrix} \Delta u_k \]

where \(z = [x; u_{\text{prev}}]\).

Parameters:

Name Type Description Default
delta_u_penalty

S — cost matrix for \(\Delta u\) (n_u, n_u).

required
**kwargs

Passed to MPC_LTI.init.

{}
Source code in src/shinro/controllers/mpc_lti.py
def __init__(self, delta_u_penalty, backend: ArrayBackend | None = None, **kwargs):
    self.bk = backend or NumpyBackend()
    self.S_delta = delta_u_penalty
    super().__init__(backend=self.bk, **kwargs)

from_config classmethod

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

Create an MPC_DeltaU controller from a TOML config dict or MPCDeltaUConfig.

Config fields

delta_u_penalty: Diagonal S weights (n_u,) or full S matrix (n_u, n_u). horizon: Prediction horizon. state_cost: Diagonal Q weights (n_x,) or full Q matrix (n_x, n_x). control_cost: Diagonal R weights (n_u,) or full R matrix (n_u, n_u). dt: Time step. A_dynamics: Optional full A matrix (n_x, n_x). Defaults to I. B_dynamics: Optional full B matrix (n_x, n_u). Defaults to dt * I. constraints: Optional dict with upper and lower bound lists.

Parameters:

Name Type Description Default
config

TOML config dict or MPCDeltaUConfig.

required
backend ArrayBackend | None

Array backend. Defaults to NumpyBackend.

None

Returns:

Type Description

MPC_LTI_DeltaU instance.

Source code in src/shinro/controllers/mpc_lti.py
@classmethod
def from_config(cls, config, backend: ArrayBackend | None = None):
    """Create an MPC_DeltaU controller from a TOML config dict or :class:`MPCDeltaUConfig`.

    Config fields:
        delta_u_penalty: Diagonal S weights (n_u,) or full S matrix (n_u, n_u).
        horizon: Prediction horizon.
        state_cost: Diagonal Q weights (n_x,) or full Q matrix (n_x, n_x).
        control_cost: Diagonal R weights (n_u,) or full R matrix (n_u, n_u).
        dt: Time step.
        A_dynamics: Optional full A matrix (n_x, n_x). Defaults to I.
        B_dynamics: Optional full B matrix (n_x, n_u). Defaults to dt * I.
        constraints: Optional dict with ``upper`` and ``lower`` bound lists.

    Args:
        config: TOML config dict or MPCDeltaUConfig.
        backend: Array backend. Defaults to NumpyBackend.

    Returns:
        MPC_LTI_DeltaU instance.
    """
    bk = backend or NumpyBackend()
    cfg = cls.parse_config(config)
    Q = parse_matrix(bk, cfg.state_cost)
    n = Q.shape[0]
    A = bk.array(cfg.A_dynamics) if cfg.A_dynamics is not None else bk.eye(n)
    if cfg.B_dynamics is not None:
        B = bk.array(cfg.B_dynamics)
    elif cfg.dt is not None:
        B = cfg.dt * bk.eye(n)
    else:
        raise ValueError("MPC_DeltaU: no B_dynamics and no dt — standalone use requires one of them")
    ctrl = cls(
        delta_u_penalty=parse_matrix(bk, cfg.delta_u_penalty),
        horizon=cfg.horizon,
        control_cost_matrix=parse_matrix(bk, cfg.control_cost),
        state_cost_matrix=Q,
        A_dynamics=A,
        B_dynamics=B,
        terminal_cost=parse_matrix(bk, cfg.terminal_cost if cfg.terminal_cost is not None else cfg.state_cost),
        backend=bk,
    )
    if cfg.constraints is not None:
        cons = strict_from_dict(ConstraintsConfig, cfg.constraints, "MPC_DeltaU.constraints")
        F = bk.array(cons.matrix) if cons.matrix is not None else bk.vstack([bk.eye(n), -bk.eye(n)])
        ctrl.constraints(F, cons.upper, cons.lower)
    return ctrl

compute

compute(current_state, target_state: Any | None = None, u_prev: Any | None = None)

Solve MPC with \(\Delta u\) regularization.

Regulates the tracking error e = current_state - target_state to zero, augmenting the state with the previous control input.

Parameters:

Name Type Description Default
current_state

Original (non-augmented) state vector (n_x,).

required
target_state Any | None

Optional reference state (n_x,) to track.

None
u_prev Any | None

Previous control input (n_u,). Defaults to zeros.

None

Returns:

Type Description

Optimal control action (n_u,).

Source code in src/shinro/controllers/mpc_lti.py
def compute(self, current_state, target_state: Any | None = None, u_prev: Any | None = None):
    """Solve MPC with :math:`\\Delta u` regularization.

    Regulates the tracking error ``e = current_state - target_state`` to
    zero, augmenting the state with the previous control input.

    Args:
        current_state: Original (non-augmented) state vector (n_x,).
        target_state: Optional reference state (n_x,) to track.
        u_prev: Previous control input (n_u,). Defaults to zeros.

    Returns:
        Optimal control action (n_u,).
    """
    if u_prev is None:
        u_prev = self.bk.zeros(self.m)
    x0 = current_state - target_state if target_state is not None else current_state
    x0_aug = self.bk.hstack([x0, u_prev])
    return super().compute(x0_aug)

MPPIController

Bases: Controller

Model Predictive Path Integral controller.

Each compute() call samples \(N\) Gaussian control perturbations, rolls out the dynamics over a horizon of \(K\) steps, weights the perturbations by the softmax of their total cost, and advances the receding-horizon nominal control sequence by one step. The returned action is the first element of the updated nominal sequence, clipped to the configured control bounds.

The dynamics and cost callables receive batched arrays of shape (N, D_x) / (N, D_u) and may be injected directly or produced by attach_plant from a plant's model.

Parameters:

Name Type Description Default
dynamics_fn Any | None

Callable dynamics_fn(x, u, dt) -> x_next stepping a batch of states forward one time step. Receives backend arrays of shape (N, D_x) and (N, D_u) and returns (N, D_x). May be None and injected later or via attach_plant.

None
cost_fn Any | None

Callable cost_fn(x, u) -> c returning the per-sample stage cost as an array of shape (N,). May be None and injected later or via attach_plant.

None
num_samples int

Number of sampled perturbations N.

100
temperature float

Softmax temperature \(\lambda\) (> 0).

1.0
dt float

Time step passed to the dynamics.

0.01
horizon int

Prediction horizon K.

10
noise_sigma

Standard deviation of the control perturbation per input channel (D_u,). If None, defaults to [0.5].

None
u_min

Optional lower control bound (D_u,) or scalar.

None
u_max

Optional upper control bound (D_u,) or scalar.

None
seed int | None

Optional RNG seed for reproducible sampling.

None
backend ArrayBackend | None

Array backend. Defaults to NumpyBackend. The rollout loop runs on this backend, so a TorchBackend executes batched tensor ops over the sample dimension.

None
Source code in src/shinro/controllers/mppi.py
def __init__(
    self,
    dynamics_fn: Any | None = None,
    cost_fn: Any | None = None,
    num_samples: int = 100,
    temperature: float = 1.0,
    dt: float = 0.01,
    horizon: int = 10,
    noise_sigma=None,
    u_min=None,
    u_max=None,
    seed: int | None = None,
    backend: ArrayBackend | None = None,
):
    self.bk = backend or NumpyBackend()
    self.dynamics_fn = dynamics_fn
    self.cost_fn = cost_fn

    if num_samples <= 0:
        raise ValueError(f"num_samples must be positive, got {num_samples}")
    if temperature <= 0:
        raise ValueError(f"temperature must be positive, got {temperature}")
    if horizon <= 0:
        raise ValueError(f"horizon must be positive, got {horizon}")
    if dt <= 0:
        raise ValueError(f"dt must be positive, got {dt}")

    self.N = num_samples
    self.K = horizon
    self.dt = float(dt)
    self.lam = float(temperature)
    self.seed = seed
    self._rng = np.random.default_rng(seed)

    if noise_sigma is None:
        noise_sigma = [0.5]
    self.noise_sigma = np.atleast_1d(np.asarray(noise_sigma, dtype=np.float64))
    self.D_u = self.noise_sigma.shape[0]

    self.u_min = np.atleast_1d(np.asarray(u_min, dtype=np.float64)) if u_min is not None else None
    self.u_max = np.atleast_1d(np.asarray(u_max, dtype=np.float64)) if u_max is not None else None

    self.u = np.zeros((self.K, self.D_u))

    self._last_epsilon = None
    self._last_costs = None
    self._adapter = None
    self._Q = None
    self._R = None
    self._x_ref = None

attach_plant

attach_plant(plant, Q: Any | None = None, R: Any | None = None)

Wire a plant into the controller via a batched dynamics adapter.

Builds a BatchedDynamicsAdapter from the plant and sets dynamics_fn / cost_fn from it. The cost uses the quadratic stage cost \(x^T Q x + u^T R u\), optionally tracking a reference passed to compute. Q and R may be diagonal (D,) or full (D, D) matrices; they default to identity.

Parameters:

Name Type Description Default
plant

A Plant exposing get_model() (and optionally dynamics() for nonlinear plants).

required
Q Any | None

State cost matrix (D_x, D_x) or diagonal (D_x,).

None
R Any | None

Control cost matrix (D_u, D_u) or diagonal (D_u,).

None

Raises:

Type Description
ValueError

If the plant's control dimension disagrees with the controller's noise_sigma length.

Source code in src/shinro/controllers/mppi.py
def attach_plant(self, plant, Q: Any | None = None, R: Any | None = None):
    """Wire a plant into the controller via a batched dynamics adapter.

    Builds a :class:`BatchedDynamicsAdapter` from the plant and sets
    ``dynamics_fn`` / ``cost_fn`` from it. The cost uses the quadratic
    stage cost :math:`x^T Q x + u^T R u`, optionally tracking a reference
    passed to :func:`compute`. ``Q`` and ``R`` may be diagonal ``(D,)`` or
    full ``(D, D)`` matrices; they default to identity.

    Args:
        plant: A :class:`Plant` exposing ``get_model()`` (and optionally
            ``dynamics()`` for nonlinear plants).
        Q: State cost matrix (D_x, D_x) or diagonal (D_x,).
        R: Control cost matrix (D_u, D_u) or diagonal (D_u,).

    Raises:
        ValueError: If the plant's control dimension disagrees with the
            controller's ``noise_sigma`` length.
    """
    from shinro.utils.batched_adapter import BatchedDynamicsAdapter

    adapter = BatchedDynamicsAdapter(plant)
    if adapter.control_dim != self.D_u:
        raise ValueError(
            f"Plant control dimension {adapter.control_dim} does not match "
            f"controller noise_sigma dimension {self.D_u}."
        )
    self._adapter = adapter
    self._Q = self._Q if Q is None else Q
    self._R = self._R if R is None else R
    self.D_x = adapter.state_dim
    self.D_u = adapter.control_dim

    self.dynamics_fn = adapter.dynamics_fn
    self.cost_fn = lambda x, u: adapter.cost_fn(x, u, self._Q, self._R, x_ref=self._x_ref)

compute

compute(current_state, target_state: Any | None = None)

Compute the MPPI control action for a given initial state.

Samples \(N\) Gaussian perturbation sequences, rolls out the dynamics over the horizon, computes the softmax-weighted update, and returns the first action of the updated nominal sequence (clipped to bounds if configured).

The rollout loop runs on self.bk, so with a TorchBackend the batched dynamics/cost operations execute as torch tensor ops. The Gaussian sampling and softmax weighting run in numpy and are bridged to the backend.

Parameters:

Name Type Description Default
current_state

Initial state vector (D_x,). Accepts the backend's native array type (numpy or torch).

required
target_state Any | None

Optional reference state (D_x,) to track. When given, the cost penalizes deviation (x - target_state); otherwise the controller regulates to the origin.

None

Returns:

Type Description

First control action (D_u,) in the backend's native type.

Raises:

Type Description
RuntimeError

If dynamics_fn or cost_fn has not been set.

Source code in src/shinro/controllers/mppi.py
def compute(self, current_state, target_state: Any | None = None):
    """Compute the MPPI control action for a given initial state.

    Samples :math:`N` Gaussian perturbation sequences, rolls out the
    dynamics over the horizon, computes the softmax-weighted update, and
    returns the first action of the updated nominal sequence (clipped to
    bounds if configured).

    The rollout loop runs on ``self.bk``, so with a :class:`TorchBackend`
    the batched dynamics/cost operations execute as torch tensor ops. The
    Gaussian sampling and softmax weighting run in numpy and are bridged
    to the backend.

    Args:
        current_state: Initial state vector (D_x,). Accepts the backend's
            native array type (numpy or torch).
        target_state: Optional reference state (D_x,) to track. When given,
            the cost penalizes deviation ``(x - target_state)``; otherwise
            the controller regulates to the origin.

    Returns:
        First control action (D_u,) in the backend's native type.

    Raises:
        RuntimeError: If ``dynamics_fn`` or ``cost_fn`` has not been set.
    """
    if self.dynamics_fn is None or self.cost_fn is None:
        raise RuntimeError(
            "dynamics_fn and cost_fn must be set before calling compute(). "
            "Inject them directly, call attach_plant(plant, ...), or if the "
            "controller was built with from_config, set ctrl.dynamics_fn = ... "
            "and ctrl.cost_fn = ..."
        )
    dynamics_fn = self.dynamics_fn
    cost_fn = self.cost_fn

    x0_np = self.bk.to_numpy(current_state)
    self._x_ref = self.bk.from_numpy(self.bk.to_numpy(target_state)) if target_state is not None else None

    epsilon = self._rng.normal(loc=0.0, scale=self.noise_sigma, size=(self.N, self.K, self.D_u))
    self._last_epsilon = epsilon

    v = np.expand_dims(self.u, axis=0) + epsilon
    if self.u_min is not None or self.u_max is not None:
        v = np.clip(v, self.u_min, self.u_max)

    # Backend-native rollout. The dynamics/cost callables operate on the
    # backend's tensors; the sampled perturbations and nominal sequence
    # live in numpy and are bridged once before the loop. Each from_numpy
    # is a CPU->GPU transfer on a torch backend, so converting the
    # loop-invariant arrays up front removes 2K transfers per compute().
    x_current = self.bk.from_numpy(np.tile(x0_np, (self.N, 1)))
    u_plan = self.bk.from_numpy(v)
    eps_b = self.bk.from_numpy(epsilon)
    u_nom_b = self.bk.from_numpy(self.u)
    sigma2_b = self.bk.from_numpy(self.noise_sigma**2)
    costs = self.bk.zeros(self.N)
    lam = self.lam

    for k in range(self.K):
        u_k = u_plan[:, k, :]
        costs = costs + cost_fn(x_current, u_k)
        inv_var_weighted_u = u_nom_b[k] / sigma2_b
        control_penalty = lam * self.bk.sum(inv_var_weighted_u * eps_b[:, k, :], axis=1)
        costs = costs + control_penalty
        x_current = dynamics_fn(x_current, u_k, self.dt)

    costs = costs + cost_fn(x_current, self.bk.zeros((self.N, self.D_u)))
    costs_np = self.bk.to_numpy(costs)
    self._last_costs = costs_np.copy()

    beta = np.min(costs_np)
    softmax_w = np.exp(-(costs_np - beta) / lam)
    softmax_w /= np.sum(softmax_w)

    weighted_eps = np.sum(softmax_w[:, np.newaxis, np.newaxis] * epsilon, axis=0)

    self.u += weighted_eps

    u_0 = self.u[0].copy()
    if self.K > 1:
        self.u[:-1] = self.u[1:]
        self.u[-1] = self.u[-2]

    if self.u_min is not None or self.u_max is not None:
        u_0 = np.clip(u_0, self.u_min, self.u_max)

    return self.bk.from_numpy(u_0)

reset

reset()

Reset the controller to its initial state.

Zeros the nominal control sequence and clears the last-sample bookkeeping attributes.

Source code in src/shinro/controllers/mppi.py
def reset(self):
    """Reset the controller to its initial state.

    Zeros the nominal control sequence and clears the last-sample
    bookkeeping attributes.
    """
    self.u = np.zeros((self.K, self.D_u))
    self._last_epsilon = None
    self._last_costs = None

from_config classmethod

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

Create an MPPI controller from a TOML config dict or MPPIConfig.

Config fields

num_samples: Number of sampled perturbations N. temperature: Softmax temperature. horizon: Prediction horizon K. noise_sigma: Per-channel perturbation std dev (D_u,). dt: Time step. Required at runtime; injected from the plant in scenario builds. u_min: Optional lower bound list (D_u,). u_max: Optional upper bound list (D_u,). seed: Optional RNG seed. state_cost: Optional diagonal Q weights (D_x,) or full Q matrix. control_cost: Optional diagonal R weights (D_u,) or full R matrix.

The dynamics_fn and cost_fn callables cannot be serialized to TOML. They are created as None and must be injected after construction, either by setting the attributes directly or by calling attach_plant(plant):

.. code-block:: python

ctrl = MPPIController.from_config(config)
ctrl.dynamics_fn = my_dynamics
ctrl.cost_fn = my_cost
# or:
ctrl.attach_plant(plant)

Parameters:

Name Type Description Default
config

TOML config dict or MPPIConfig.

required
backend ArrayBackend | None

Array backend. Defaults to NumpyBackend.

None

Returns:

Type Description

MPPIController instance.

Source code in src/shinro/controllers/mppi.py
@classmethod
def from_config(cls, config, backend: ArrayBackend | None = None):
    """Create an MPPI controller from a TOML config dict or :class:`MPPIConfig`.

    Config fields:
        num_samples: Number of sampled perturbations N.
        temperature: Softmax temperature.
        horizon: Prediction horizon K.
        noise_sigma: Per-channel perturbation std dev (D_u,).
        dt: Time step. Required at runtime; injected from the plant in
            scenario builds.
        u_min: Optional lower bound list (D_u,).
        u_max: Optional upper bound list (D_u,).
        seed: Optional RNG seed.
        state_cost: Optional diagonal Q weights (D_x,) or full Q matrix.
        control_cost: Optional diagonal R weights (D_u,) or full R matrix.

    The ``dynamics_fn`` and ``cost_fn`` callables cannot be serialized to
    TOML. They are created as ``None`` and must be injected after
    construction, either by setting the attributes directly or by calling
    ``attach_plant(plant)``:

    .. code-block:: python

        ctrl = MPPIController.from_config(config)
        ctrl.dynamics_fn = my_dynamics
        ctrl.cost_fn = my_cost
        # or:
        ctrl.attach_plant(plant)

    Args:
        config: TOML config dict or MPPIConfig.
        backend: Array backend. Defaults to NumpyBackend.

    Returns:
        MPPIController instance.
    """
    bk = backend or NumpyBackend()
    cfg = cls.parse_config(config)
    if cfg.dt is None:
        raise ValueError(
            "MPPI: dt is required (rollout stepping) — omit it only in scenario "
            "builds, where the plant's dt is injected"
        )
    ctrl = cls(
        dynamics_fn=None,
        cost_fn=None,
        num_samples=cfg.num_samples,
        temperature=cfg.temperature,
        dt=cfg.dt,
        horizon=cfg.horizon,
        noise_sigma=cfg.noise_sigma,
        u_min=cfg.u_min,
        u_max=cfg.u_max,
        seed=cfg.seed,
        backend=bk,
    )
    ctrl._Q = bk.array(cfg.state_cost) if cfg.state_cost is not None else None
    ctrl._R = bk.array(cfg.control_cost) if cfg.control_cost is not None else None
    return ctrl

LeRobotDiffusionAdapter

Bases: Controller

Wrap a LeRobot diffusion policy as a Controller.

The policy is loaded from a Hugging Face checkpoint or local path. The compute() method converts the plant state into LeRobot's observation dict format, runs the policy, and returns the action.

Accepts both numpy arrays and torch tensors for state and camera frames, so it works with any ArrayBackend.

Parameters:

Name Type Description Default
policy

Loaded LeRobot policy instance.

required
use_camera bool

Whether to expect camera observations.

False
device str

Torch device for policy inference.

'cpu'
Source code in src/shinro/controllers/lerobot_adapter.py
def __init__(self, policy, use_camera: bool = False, device: str = "cpu"):
    self.policy = policy
    self.use_camera = use_camera
    self.device = device
    self._latest_camera_frame = None

update_camera

update_camera(frame)

Feed a camera frame for the next policy step.

Parameters:

Name Type Description Default
frame

Camera image as numpy array (HWC) or torch tensor (CHW or HWC).

required
Source code in src/shinro/controllers/lerobot_adapter.py
def update_camera(self, frame):
    """Feed a camera frame for the next policy step.

    Args:
        frame: Camera image as numpy array (HWC) or torch tensor (CHW or HWC).
    """
    self._latest_camera_frame = frame

compute

compute(state, target=None)

Run the LeRobot policy on the current state.

Builds a LeRobot observation dict from the state vector, runs the policy, and returns the action as a numpy array.

Parameters:

Name Type Description Default
state

Plant state vector (n_x,). Can be numpy or torch tensor.

required
target

Ignored for learned policies — they generate actions from observation alone.

None

Returns:

Type Description

Action vector (n_u,) as numpy array.

Source code in src/shinro/controllers/lerobot_adapter.py
def compute(self, state, target=None):
    """Run the LeRobot policy on the current state.

    Builds a LeRobot observation dict from the state vector, runs the
    policy, and returns the action as a numpy array.

    Args:
        state: Plant state vector (n_x,). Can be numpy or torch tensor.
        target: Ignored for learned policies — they generate actions
            from observation alone.

    Returns:
        Action vector (n_u,) as numpy array.
    """
    import torch

    if isinstance(state, torch.Tensor):
        obs = {"observation.state": state.float().unsqueeze(0)}
    else:
        obs = {"observation.state": torch.from_numpy(state).float().unsqueeze(0)}

    if self.use_camera and self._latest_camera_frame is not None:
        frame = self._latest_camera_frame
        if isinstance(frame, torch.Tensor):
            frame_tensor = frame.float()
        else:
            frame_tensor = torch.from_numpy(frame).float()
        if frame_tensor.ndim == 3:
            frame_tensor = frame_tensor.permute(2, 0, 1)
        obs["observation.images.cam"] = frame_tensor.unsqueeze(0)
        self._latest_camera_frame = None

    obs = {k: v.to(self.device) for k, v in obs.items()}
    with torch.no_grad():
        action = self.policy.select_action(obs)

    return action.squeeze(0).cpu().numpy()

reset

reset()

Reset the policy's internal state.

Source code in src/shinro/controllers/lerobot_adapter.py
def reset(self):
    """Reset the policy's internal state."""
    self.policy.reset()
    self._latest_camera_frame = None

from_config classmethod

from_config(config)

Load a LeRobot policy from a config dict.

Config fields

policy_type: Type of policy ("diffusion", "act", "pi0", etc.). checkpoint: Hugging Face repo ID or local path. use_camera: Whether to expect camera observations (default: false). device: Torch device (default: "cpu").

Parameters:

Name Type Description Default
config

TOML config dict.

required

Returns:

Type Description

LeRobotDiffusionAdapter instance.

Source code in src/shinro/controllers/lerobot_adapter.py
@classmethod
def from_config(cls, config):
    """Load a LeRobot policy from a config dict.

    Config fields:
        policy_type: Type of policy (``"diffusion"``, ``"act"``, ``"pi0"``, etc.).
        checkpoint: Hugging Face repo ID or local path.
        use_camera: Whether to expect camera observations (default: false).
        device: Torch device (default: ``"cpu"``).

    Args:
        config: TOML config dict.

    Returns:
        LeRobotDiffusionAdapter instance.
    """
    policy_type = config["policy_type"]
    checkpoint = config["checkpoint"]
    use_camera = config.get("use_camera", False)
    device = config.get("device", "cpu")

    from lerobot.policies import make_policy  # type: ignore
    policy = make_policy(policy_type, pretrained_path=checkpoint)
    policy.to(device)
    policy.eval()

    return cls(policy, use_camera=use_camera, device=device)

OnnxRLAdapter

Bases: Controller

Wrap an ONNX-exported RL policy as a Controller.

The policy is loaded from a local .onnx file via onnxruntime. compute() encodes the plant state (normalization / clipping / index selection), runs the model, post-processes the raw output into a control action, and returns it as a numpy array.

Parameters:

Name Type Description Default
session

Loaded onnxruntime.InferenceSession.

required
obs_encoder _ObsEncoder

Encoder mapping plant state to the ONNX feed dict.

required
output_name str

ONNX output tensor name.

required
action_space str

"continuous", "discrete", or "stochastic".

'continuous'
deterministic bool

For discrete/stochastic policies, return the greedy argmax / mean instead of sampling (default: True).

True
action_scale float | ndarray

Post-policy per-action scaling (tanh-squash support).

1.0
action_bias float | ndarray

Post-policy per-action bias.

0.0
action_clip tuple[float, float] | None

Optional (low, high) tuple to clip the final action.

None
seed int

RNG seed for sampling action spaces.

0
backend ArrayBackend | None

Array backend for state input and action output. ONNX inference itself always runs on numpy arrays, but the adapter converts the backend-native state to numpy at the boundary and the resulting action back to the backend's native type.

None
Source code in src/shinro/controllers/onnx_rl_adapter.py
def __init__(
    self,
    weights,
    obs_encoder: _ObsEncoder,
    output_name: str,
    action_space: str = "continuous",
    deterministic: bool = True,
    action_scale: float | np.ndarray = 1.0,
    action_bias: float | np.ndarray = 0.0,
    action_clip: tuple[float, float] | None = None,
    seed: int = 0,
    backend: ArrayBackend | None = None,
) -> None:
    if action_space not in ("continuous", "discrete", "stochastic"):
        raise ValueError(f"action_space must be continuous/discrete/stochastic, got {action_space!r}")
    self.session = weights
    self.obs_encoder = obs_encoder
    self.output_name = output_name
    self.action_space = action_space
    self.deterministic = deterministic
    self.action_scale = np.asarray(action_scale, dtype=np.float32)
    self.action_bias = np.asarray(action_bias, dtype=np.float32)
    self.action_clip = action_clip
    self.seed = seed
    self.bk = backend or NumpyBackend()
    self._rng = np.random.default_rng(seed)

compute

compute(state, target=None)

Run the ONNX policy on the current state.

Parameters:

Name Type Description Default
state

Plant state vector in the configured backend's native type (numpy array or torch tensor).

required
target

Ignored for learned policies — they generate actions from observation alone.

None

Returns:

Type Description

Action vector (n_u,) in the backend-native type.

Source code in src/shinro/controllers/onnx_rl_adapter.py
def compute(self, state, target=None):
    """Run the ONNX policy on the current state.

    Args:
        state: Plant state vector in the configured backend's native
            type (numpy array or torch tensor).
        target: Ignored for learned policies — they generate actions
            from observation alone.

    Returns:
        Action vector (n_u,) in the backend-native type.
    """
    feed = self.obs_encoder.encode(state)
    raw = self.session.run([self.output_name], feed)[0]
    action = self._postprocess(raw).reshape(-1)
    return self.bk.from_numpy(action)

reset

reset()

Reset the policy RNG to the configured seed.

Source code in src/shinro/controllers/onnx_rl_adapter.py
def reset(self):
    """Reset the policy RNG to the configured seed."""
    self._rng = np.random.default_rng(self.seed)

from_config classmethod

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

Create an OnnxRLAdapter from a TOML config dict.

Config fields

model_path: Path to the .onnx model file (required). action_space: "continuous", "discrete", or "stochastic" (default: "continuous"). deterministic: Whether to return argmax/mean instead of sampling (default: true). action_scale: Post-policy action scale (default: 1.0). action_bias: Post-policy action bias (default: 0.0). action_clip_low / action_clip_high: Clip the final action (default: no clipping). seed: RNG seed for stochastic sampling (default: 0).

[observation] subtable fields: input_name: ONNX input tensor name (defaults to the model's first input). state_keys: Integer indices into the plant state to use as observations (default: [0, 1, ..., n-1]). normalize: Apply mean/std normalization (default: false). obs_mean / obs_std: Arrays for normalization. clip: [low, high] observation clipping (default: none). add_batch_dim: Prepend a batch axis (default: true).

Parameters:

Name Type Description Default
config

TOML config dict.

required
backend ArrayBackend | None

Array backend for state input and action output. Defaults to NumpyBackend.

None

Returns:

Type Description

OnnxRLAdapter instance.

Source code in src/shinro/controllers/onnx_rl_adapter.py
@classmethod
def from_config(cls, config, backend: ArrayBackend | None = None):
    """Create an OnnxRLAdapter from a TOML config dict.

    Config fields:
        model_path: Path to the ``.onnx`` model file (required).
        action_space: ``"continuous"``, ``"discrete"``, or ``"stochastic"``
            (default: ``"continuous"``).
        deterministic: Whether to return argmax/mean instead of sampling
            (default: ``true``).
        action_scale: Post-policy action scale (default: 1.0).
        action_bias: Post-policy action bias (default: 0.0).
        action_clip_low / action_clip_high: Clip the final action
            (default: no clipping).
        seed: RNG seed for stochastic sampling (default: 0).

    ``[observation]`` subtable fields:
        input_name: ONNX input tensor name (defaults to the model's first
            input).
        state_keys: Integer indices into the plant state to use as
            observations (default: ``[0, 1, ..., n-1]``).
        normalize: Apply mean/std normalization (default: false).
        obs_mean / obs_std: Arrays for normalization.
        clip: ``[low, high]`` observation clipping (default: none).
        add_batch_dim: Prepend a batch axis (default: true).

    Args:
        config: TOML config dict.
        backend: Array backend for state input and action output.
            Defaults to NumpyBackend.

    Returns:
        OnnxRLAdapter instance.
    """
    import onnxruntime  # type: ignore

    bk = backend or NumpyBackend()

    session = onnxruntime.InferenceSession(config["model_path"], providers=["CPUExecutionProvider"])
    output_name = config.get("output_name")
    if output_name is None:
        output_name = session.get_outputs()[0].name

    obs_cfg = config.get("observation", {})
    input_name = obs_cfg.get("input_name")
    if input_name is None:
        input_name = session.get_inputs()[0].name

    state_keys = obs_cfg.get("state_keys")
    if state_keys is None:
        state_keys = list(range(session.get_inputs()[0].shape[1] or 0))

    obs_mean = None
    obs_std = None
    if obs_cfg.get("normalize", False):
        obs_mean = np.asarray(obs_cfg["obs_mean"], dtype=np.float32)
        obs_std = np.asarray(obs_cfg["obs_std"], dtype=np.float32)

    obs_clip = None
    if "clip" in obs_cfg:
        obs_clip = (float(obs_cfg["clip"][0]), float(obs_cfg["clip"][1]))

    encoder = _ObsEncoder(
        input_name=input_name,
        state_keys=state_keys,
        obs_mean=obs_mean,
        obs_std=obs_std,
        clip=obs_clip,
        add_batch_dim=obs_cfg.get("add_batch_dim", True),
    )

    action_clip = None
    if "action_clip_low" in config or "action_clip_high" in config:
        action_clip = (float(config.get("action_clip_low", -np.inf)), float(config.get("action_clip_high", np.inf)))

    return cls(
        weights=session,
        obs_encoder=encoder,
        output_name=output_name,
        action_space=config.get("action_space", "continuous"),
        deterministic=config.get("deterministic", True),
        action_scale=config.get("action_scale", 1.0),
        action_bias=config.get("action_bias", 0.0),
        action_clip=action_clip,
        seed=config.get("seed", 0),
        backend=bk,
    )

SlidingModeController

Bases: Controller

Sliding Mode Controller for nonlinear systems.

Implements the equivalent control approach with a switching term and optional boundary-layer smoothing. The sliding surface coefficients c must form a Hurwitz polynomial.

Parameters:

Name Type Description Default
c

Sliding surface coefficients (n,). The polynomial c[0] + c[1] p + ... + c[n-1] p^{n-1} must be Hurwitz.

required
k1 float

Discontinuous (switching) gain — drives the state to the surface.

required
phi float

Boundary layer thickness for chattering suppression. If 0, uses sign (pure switching).

0.0
k2 float

Linear (proportional) gain on the sliding variable.

0.0
smoother str

Boundary-layer smoothing function. One of "sat", "tanh", or "sigmoid".

'sat'
alpha float

Fractional power exponent for the switching term \(|s|^\alpha\). 0 gives sign-only; 1 gives linear.

0.0
backend ArrayBackend | None

Array backend. Defaults to NumpyBackend.

None
Source code in src/shinro/controllers/smc.py
def __init__(
    self,
    c,
    k1: float,
    phi: float = 0.0,
    k2: float = 0.0,
    smoother: str = "sat",
    alpha: float = 0.0,
    backend: ArrayBackend | None = None,
):
    self.bk = backend or NumpyBackend()
    self.c = self.bk.array(c).flatten()
    self.k1 = float(k1)
    self.k2 = float(k2)
    self.phi = float(phi)
    self.alpha = float(alpha)

    SMOOTHERS = self._dict_boundaries()

    if smoother not in SMOOTHERS:
        raise ValueError(f"Unknown smoother '{smoother}'. Options: {list(SMOOTHERS)}")

    if not self._is_hurwitz():
        raise ValueError(
            "Sliding surface coefficients are not Hurwitz. "
            "The polynomial c1 + c2 p + ... + cn p^{n-1} must have "
            "all roots with negative real parts."
        )

    self._smoother = SMOOTHERS[smoother]
    self._smoother_name = smoother

n property

n: int

Number of sliding surface coefficients (state dimension).

compute

compute(x, f_x, g_x)

Compute the sliding mode control action.

Evaluates \(u = (c^T g)^{-1} ( -c^T f - k_1 |s|^\alpha \, \text{smooth}(s) - k_2 s )\).

For scalar input (c^T g is scalar), uses direct division. For vector input, solves the least-squares problem.

Parameters:

Name Type Description Default
x

Current state vector (n,).

required
f_x

Drift dynamics \(f(x)\) evaluated at x (n,).

required
g_x

Control matrix \(g(x)\) evaluated at x (n, n_u).

required

Returns:

Type Description

Control input vector (n_u,).

Raises:

Type Description
RuntimeError

If \(c^T g(x)\) is near-zero for scalar input.

Source code in src/shinro/controllers/smc.py
def compute(self, x, f_x, g_x):
    """Compute the sliding mode control action.

    Evaluates :math:`u = (c^T g)^{-1} ( -c^T f - k_1 |s|^\\alpha \\, \\text{smooth}(s) - k_2 s )`.

    For scalar input (``c^T g`` is scalar), uses direct division. For
    vector input, solves the least-squares problem.

    Args:
        x: Current state vector (n,).
        f_x: Drift dynamics :math:`f(x)` evaluated at x (n,).
        g_x: Control matrix :math:`g(x)` evaluated at x (n, n_u).

    Returns:
        Control input vector (n_u,).

    Raises:
        RuntimeError: If :math:`c^T g(x)` is near-zero for scalar input.
    """
    x = self.bk.array(x).flatten()
    f_x = self.bk.array(f_x).flatten()
    g_x = self.bk.array(g_x)

    s = self.c @ x
    cf = self.c @ f_x
    cg = self.c @ g_x

    if self.phi > 0:
        smooth_s = self._smoother(s)
    else:
        smooth_s = self.bk.sign(s)

    s_dot_desired = -self.k1 * self.bk.abs(s) ** self.alpha * smooth_s - self.k2 * s

    cg_flat = self.bk.ravel(cg)
    cg_size = self.bk.to_numpy(cg_flat).size
    if cg_size == 1:
        cg_val = float(self.bk.to_numpy(cg_flat)[0])
        if abs(cg_val) < 1e-12:
            raise RuntimeError("c^T g(x) is near-zero — loss of controllability")
        u = self.bk.array([(s_dot_desired - cf) / cg_val])
    else:
        cg_np = self.bk.to_numpy(cg_flat).reshape(1, -1)
        rhs_np = np.array([float(self.bk.to_numpy(s_dot_desired - cf))])
        u_np, _, _, _ = np.linalg.lstsq(cg_np, rhs_np, rcond=None)
        u = self.bk.from_numpy(u_np.flatten())

    return u

reset

reset()

No internal state to reset for SMC.

Source code in src/shinro/controllers/smc.py
def reset(self):
    """No internal state to reset for SMC."""

from_config classmethod

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

Create an SMC controller from a TOML config dict or SMCConfig.

Config fields

c: List of sliding surface coefficients (n,). k1: Discontinuous (switching) gain. phi: Boundary layer thickness (default 0.0). k2: Linear gain on sliding variable (default 0.0). smoother: Smoothing function — "sat", "tanh", or "sigmoid" (default "sat"). alpha: Fractional power exponent (default 0.0).

Parameters:

Name Type Description Default
config

TOML config dict or SMCConfig.

required
backend ArrayBackend | None

Array backend. Defaults to NumpyBackend.

None

Returns:

Type Description

SlidingModeController instance.

Source code in src/shinro/controllers/smc.py
@classmethod
def from_config(cls, config, backend: ArrayBackend | None = None):
    """Create an SMC controller from a TOML config dict or :class:`SMCConfig`.

    Config fields:
        c: List of sliding surface coefficients (n,).
        k1: Discontinuous (switching) gain.
        phi: Boundary layer thickness (default 0.0).
        k2: Linear gain on sliding variable (default 0.0).
        smoother: Smoothing function — ``"sat"``, ``"tanh"``, or
            ``"sigmoid"`` (default ``"sat"``).
        alpha: Fractional power exponent (default 0.0).

    Args:
        config: TOML config dict or SMCConfig.
        backend: Array backend. Defaults to NumpyBackend.

    Returns:
        SlidingModeController instance.
    """
    bk = backend or NumpyBackend()
    cfg = cls.parse_config(config)
    return cls(
        c=bk.array(cfg.c),
        k1=cfg.k1,
        phi=cfg.phi,
        k2=cfg.k2,
        smoother=cfg.smoother,
        alpha=cfg.alpha,
        backend=bk,
    )