Skip to content

Simulator

The Simulator class is the central orchestrator of OpenSMC. It manages the interaction between the plant, the controller, and the optional estimator to perform closed-loop simulations using fixed-step solvers like RK4 or Euler.

Usage Example

from opensmc import Simulator
from opensmc.plants import DoubleIntegrator
from opensmc.controllers import ClassicalSMC

# Setup
plant = DoubleIntegrator()
controller = ClassicalSMC()  # Using default surface and reaching law

# Create simulator with time parameters
sim = Simulator(dt=0.001, T=5.0, method='rk4')

# Run simulation — returns dict with 't', 'x', 'u', 's', 'ref'
ref_fn = lambda t: [0.0, 0.0]
result = sim.run(controller, plant, ref_fn=ref_fn)

API Reference

simulator

OpenSMC Simulator — Fixed-step RK4/Euler closed-loop integration engine.

Classes

Simulator

Fixed-step closed-loop simulator.

Parameters

dt : float — integration time step (seconds) T : float — total simulation time (seconds) method : str — 'rk4' or 'euler'

Usage

sim = Simulator(dt=1e-4, T=10.0) result = sim.run(controller, plant, ref_fn, dist_fn)

Source code in opensmc/simulator.py
class Simulator:
    """Fixed-step closed-loop simulator.

    Parameters
    ----------
    dt : float — integration time step (seconds)
    T : float — total simulation time (seconds)
    method : str — 'rk4' or 'euler'

    Usage
    -----
    >>> sim = Simulator(dt=1e-4, T=10.0)
    >>> result = sim.run(controller, plant, ref_fn, dist_fn)
    """

    def __init__(self, dt=1e-4, T=10.0, method='rk4'):
        self.dt = dt
        self.T = T
        self.method = method
        self._step = rk4_step if method == 'rk4' else euler_step

    def run(self, controller, plant, ref_fn=None, dist_fn=None, x0=None):
        """Run closed-loop simulation.

        Parameters
        ----------
        controller : Controller — computes u from (t, x, xref, plant)
        plant : Plant — dynamics
        ref_fn : callable(t) -> ndarray or None — reference signal
        dist_fn : callable(t) -> ndarray or None — disturbance signal
        x0 : ndarray or None — initial state (uses plant default if None)

        Returns
        -------
        result : dict with keys:
            t : ndarray (N,) — time array
            x : ndarray (N, n_states) — state trajectory
            u : ndarray (N, n_inputs) — control history
            xref : ndarray (N, n_states) — reference history
            e : ndarray (N, n_states) — error history
            s : ndarray (N,) or (N, n_s) — sliding variable history
        """
        dt = self.dt
        N = int(self.T / dt)
        n = plant.n_states

        if x0 is None:
            x0 = plant.get_default_x0()
        x = np.array(x0, dtype=float)

        # Determine input dimension from first control call
        if ref_fn is None:
            ref_fn = lambda t: np.zeros(n)
        if dist_fn is None:
            dist_fn = lambda t: np.zeros(n)

        # Pre-allocate
        t_arr = np.zeros(N + 1)
        x_arr = np.zeros((N + 1, n))
        xref_arr = np.zeros((N + 1, n))

        # First pass to determine u and s dimensions
        xref0 = np.atleast_1d(ref_fn(0.0))
        if len(xref0) < n:
            xref0 = np.concatenate([xref0, np.zeros(n - len(xref0))])

        u0, info0 = controller.compute(0.0, x, xref0, plant)
        u0 = np.atleast_1d(u0)
        n_u = len(u0)
        s0 = info0.get('s', 0.0)
        s0 = np.atleast_1d(s0)
        n_s = len(s0)

        u_arr = np.zeros((N + 1, n_u))
        s_arr = np.zeros((N + 1, n_s))

        controller.reset()

        for i in range(N + 1):
            t = i * dt
            xref = np.atleast_1d(ref_fn(t))
            if len(xref) < n:
                xref = np.concatenate([xref, np.zeros(n - len(xref))])
            d = np.atleast_1d(dist_fn(t))
            if len(d) < n_u:
                d = np.concatenate([d, np.zeros(n_u - len(d))])
            elif len(d) > n_u:
                d = d[:n_u]

            u, info = controller.compute(t, x, xref, plant)
            u = np.atleast_1d(u)

            t_arr[i] = t
            x_arr[i] = x
            xref_arr[i] = xref
            u_arr[i] = u
            s_arr[i] = np.atleast_1d(info.get('s', 0.0))

            if i < N:
                def f_dyn(tt, xx, uu=u, dd=d):
                    return plant.dynamics(tt, xx, uu, dd)
                x = self._step(f_dyn, t, x, dt)

        return {
            't': t_arr,
            'x': x_arr,
            'u': u_arr,
            'xref': xref_arr,
            'e': xref_arr - x_arr,
            's': s_arr,
        }
Functions
run(controller, plant, ref_fn=None, dist_fn=None, x0=None)

Run closed-loop simulation.

Parameters

controller : Controller — computes u from (t, x, xref, plant) plant : Plant — dynamics ref_fn : callable(t) -> ndarray or None — reference signal dist_fn : callable(t) -> ndarray or None — disturbance signal x0 : ndarray or None — initial state (uses plant default if None)

Returns

result : dict with keys: t : ndarray (N,) — time array x : ndarray (N, n_states) — state trajectory u : ndarray (N, n_inputs) — control history xref : ndarray (N, n_states) — reference history e : ndarray (N, n_states) — error history s : ndarray (N,) or (N, n_s) — sliding variable history

Source code in opensmc/simulator.py
def run(self, controller, plant, ref_fn=None, dist_fn=None, x0=None):
    """Run closed-loop simulation.

    Parameters
    ----------
    controller : Controller — computes u from (t, x, xref, plant)
    plant : Plant — dynamics
    ref_fn : callable(t) -> ndarray or None — reference signal
    dist_fn : callable(t) -> ndarray or None — disturbance signal
    x0 : ndarray or None — initial state (uses plant default if None)

    Returns
    -------
    result : dict with keys:
        t : ndarray (N,) — time array
        x : ndarray (N, n_states) — state trajectory
        u : ndarray (N, n_inputs) — control history
        xref : ndarray (N, n_states) — reference history
        e : ndarray (N, n_states) — error history
        s : ndarray (N,) or (N, n_s) — sliding variable history
    """
    dt = self.dt
    N = int(self.T / dt)
    n = plant.n_states

    if x0 is None:
        x0 = plant.get_default_x0()
    x = np.array(x0, dtype=float)

    # Determine input dimension from first control call
    if ref_fn is None:
        ref_fn = lambda t: np.zeros(n)
    if dist_fn is None:
        dist_fn = lambda t: np.zeros(n)

    # Pre-allocate
    t_arr = np.zeros(N + 1)
    x_arr = np.zeros((N + 1, n))
    xref_arr = np.zeros((N + 1, n))

    # First pass to determine u and s dimensions
    xref0 = np.atleast_1d(ref_fn(0.0))
    if len(xref0) < n:
        xref0 = np.concatenate([xref0, np.zeros(n - len(xref0))])

    u0, info0 = controller.compute(0.0, x, xref0, plant)
    u0 = np.atleast_1d(u0)
    n_u = len(u0)
    s0 = info0.get('s', 0.0)
    s0 = np.atleast_1d(s0)
    n_s = len(s0)

    u_arr = np.zeros((N + 1, n_u))
    s_arr = np.zeros((N + 1, n_s))

    controller.reset()

    for i in range(N + 1):
        t = i * dt
        xref = np.atleast_1d(ref_fn(t))
        if len(xref) < n:
            xref = np.concatenate([xref, np.zeros(n - len(xref))])
        d = np.atleast_1d(dist_fn(t))
        if len(d) < n_u:
            d = np.concatenate([d, np.zeros(n_u - len(d))])
        elif len(d) > n_u:
            d = d[:n_u]

        u, info = controller.compute(t, x, xref, plant)
        u = np.atleast_1d(u)

        t_arr[i] = t
        x_arr[i] = x
        xref_arr[i] = xref
        u_arr[i] = u
        s_arr[i] = np.atleast_1d(info.get('s', 0.0))

        if i < N:
            def f_dyn(tt, xx, uu=u, dd=d):
                return plant.dynamics(tt, xx, uu, dd)
            x = self._step(f_dyn, t, x, dt)

    return {
        't': t_arr,
        'x': x_arr,
        'u': u_arr,
        'xref': xref_arr,
        'e': xref_arr - x_arr,
        's': s_arr,
    }

Functions

euler_step(f, t, x, dt, *args)

Single forward Euler integration step.

Source code in opensmc/simulator.py
def euler_step(f, t, x, dt, *args):
    """Single forward Euler integration step."""
    return x + dt * f(t, x, *args)

rk4_step(f, t, x, dt, *args)

Single RK4 integration step for dx/dt = f(t, x, *args).

Source code in opensmc/simulator.py
def rk4_step(f, t, x, dt, *args):
    """Single RK4 integration step for dx/dt = f(t, x, *args)."""
    k1 = f(t, x, *args)
    k2 = f(t + dt / 2, x + dt / 2 * k1, *args)
    k3 = f(t + dt / 2, x + dt / 2 * k2, *args)
    k4 = f(t + dt, x + dt * k3, *args)
    return x + (dt / 6.0) * (k1 + 2 * k2 + 2 * k3 + k4)