Skip to content

Sliding Surfaces

Sliding surfaces define the manifold in the error state-space where the system should ideally reside. OpenSMC provides 11 different sliding surface implementations, ranging from classical linear surfaces to advanced predefined-time and hierarchical manifolds.

Usage Example

from opensmc.surfaces import NonsingularTerminalSurface

# Create a nonsingular terminal sliding surface
surface = NonsingularTerminalSurface(alpha=2.0, beta=1.5, p=5, q=3)

# Compute surface value for a given error vector
s = surface.compute(error=[0.5, 0.1])

Available Surfaces

surfaces

OpenSMC Surfaces — 11 sliding surface implementations + RL-discovered.

Classes

FastTerminalSurface

Bases: SlidingSurface

Fast terminal sliding surface (Liu & Wang, 2012).

.. math:: s = \dot{e} + \alpha\,e + \beta\,|e|^{q/p}\,\mathrm{sign}(e)

Combines a linear term (alpha * e) for global convergence with a terminal term (beta * |e|^{q/p} * sign(e)) for finite-time convergence near the origin. This ensures global finite-time stability.

.. note:: The exponent here is q/p > 1 (not p/q < 1 as in the standard terminal surface), following Liu & Wang (2012) Ch 7.3.

Parameters

alpha : float Linear gain (positive). beta : float Terminal gain (positive). p : int Numerator (odd positive integer, p < q). q : int Denominator (odd positive integer).

Source code in opensmc/surfaces/terminal.py
class FastTerminalSurface(SlidingSurface):
    r"""Fast terminal sliding surface (Liu & Wang, 2012).

    .. math::
        s = \dot{e} + \alpha\,e + \beta\,|e|^{q/p}\,\mathrm{sign}(e)

    Combines a linear term (alpha * e) for global convergence with a
    terminal term (beta * |e|^{q/p} * sign(e)) for finite-time
    convergence near the origin. This ensures global finite-time
    stability.

    .. note::
        The exponent here is q/p > 1 (not p/q < 1 as in the standard
        terminal surface), following Liu & Wang (2012) Ch 7.3.

    Parameters
    ----------
    alpha : float
        Linear gain (positive).
    beta : float
        Terminal gain (positive).
    p : int
        Numerator (odd positive integer, p < q).
    q : int
        Denominator (odd positive integer).
    """

    def __init__(self, alpha=2.0, beta=1.0, p=5, q=9):
        if alpha <= 0:
            raise ValueError(f"alpha must be positive, got {alpha}")
        if beta <= 0:
            raise ValueError(f"beta must be positive, got {beta}")
        if p >= q:
            raise ValueError(f"Require p < q, got p={p}, q={q}")
        if p % 2 == 0 or q % 2 == 0:
            raise ValueError(f"p and q must be odd, got p={p}, q={q}")
        self.alpha = alpha
        self.beta = beta
        self.p = p
        self.q = q

    def compute(self, e, edot, t=0.0, **kwargs):
        r"""Compute s = edot + alpha * e + beta * |e|^{q/p} * sign(e)."""
        return edot + self.alpha * e + self.beta * _frac_power(e, self.q, self.p)
Functions
compute(e, edot, t=0.0, **kwargs)

Compute s = edot + alpha * e + beta * |e|^{q/p} * sign(e).

Source code in opensmc/surfaces/terminal.py
def compute(self, e, edot, t=0.0, **kwargs):
    r"""Compute s = edot + alpha * e + beta * |e|^{q/p} * sign(e)."""
    return edot + self.alpha * e + self.beta * _frac_power(e, self.q, self.p)

GlobalSurface

Bases: SlidingSurface

Global sliding surface with exponential correction.

.. math:: s(t) = \dot{e} + c\,e - \bigl(\dot{e}(0) + c\,e(0)\bigr)\,\exp(-\alpha\,t)

Key property: s(0) = 0 by construction, regardless of initial conditions. The exponential correction decays with rate alpha, and as t -> infinity the surface converges to the standard linear surface s = edot + c * e.

The transition time from global to linear behavior is approximately:

.. math:: t_\epsilon = -\frac{\ln(\epsilon / |s_0|)}{\alpha}

Parameters

c : float Base surface slope (positive). alpha : float Exponential decay rate for the correction term (positive). Larger alpha means faster transition to the standard surface.

Source code in opensmc/surfaces/global_surface.py
class GlobalSurface(SlidingSurface):
    r"""Global sliding surface with exponential correction.

    .. math::
        s(t) = \dot{e} + c\,e
               - \bigl(\dot{e}(0) + c\,e(0)\bigr)\,\exp(-\alpha\,t)

    **Key property:** s(0) = 0 by construction, regardless of initial
    conditions. The exponential correction decays with rate alpha, and
    as t -> infinity the surface converges to the standard linear
    surface s = edot + c * e.

    The transition time from global to linear behavior is approximately:

    .. math::
        t_\epsilon = -\frac{\ln(\epsilon / |s_0|)}{\alpha}

    Parameters
    ----------
    c : float
        Base surface slope (positive).
    alpha : float
        Exponential decay rate for the correction term (positive).
        Larger alpha means faster transition to the standard surface.
    """

    def __init__(self, c=10.0, alpha=5.0):
        if c <= 0:
            raise ValueError(f"c must be positive, got {c}")
        if alpha <= 0:
            raise ValueError(f"alpha must be positive, got {alpha}")
        self.c = c
        self.alpha = alpha
        self._e0 = None
        self._edot0 = None
        self._s0 = None
        self._initialized = False

    def compute(self, e, edot, t=0.0, **kwargs):
        r"""Compute the global sliding variable.

        On first call, stores e(0) and edot(0) to compute the
        correction term s_0 = edot(0) + c * e(0).

        Parameters
        ----------
        e : float
            Tracking error.
        edot : float
            Error derivative.
        t : float
            Current time (required for the exponential decay).

        Returns
        -------
        s : float
            Sliding variable (exactly zero at t = 0).
        """
        if not self._initialized:
            self._e0 = e
            self._edot0 = edot
            self._s0 = edot + self.c * e
            self._initialized = True

        s_nominal = edot + self.c * e
        correction = self._s0 * np.exp(-self.alpha * t)
        return s_nominal - correction

    def reset(self):
        """Reset the stored initial conditions."""
        self._e0 = None
        self._edot0 = None
        self._s0 = None
        self._initialized = False
Functions
compute(e, edot, t=0.0, **kwargs)

Compute the global sliding variable.

On first call, stores e(0) and edot(0) to compute the correction term s_0 = edot(0) + c * e(0).

Parameters

e : float Tracking error. edot : float Error derivative. t : float Current time (required for the exponential decay).

Returns

s : float Sliding variable (exactly zero at t = 0).

Source code in opensmc/surfaces/global_surface.py
def compute(self, e, edot, t=0.0, **kwargs):
    r"""Compute the global sliding variable.

    On first call, stores e(0) and edot(0) to compute the
    correction term s_0 = edot(0) + c * e(0).

    Parameters
    ----------
    e : float
        Tracking error.
    edot : float
        Error derivative.
    t : float
        Current time (required for the exponential decay).

    Returns
    -------
    s : float
        Sliding variable (exactly zero at t = 0).
    """
    if not self._initialized:
        self._e0 = e
        self._edot0 = edot
        self._s0 = edot + self.c * e
        self._initialized = True

    s_nominal = edot + self.c * e
    correction = self._s0 * np.exp(-self.alpha * t)
    return s_nominal - correction
reset()

Reset the stored initial conditions.

Source code in opensmc/surfaces/global_surface.py
def reset(self):
    """Reset the stored initial conditions."""
    self._e0 = None
    self._edot0 = None
    self._s0 = None
    self._initialized = False

HierarchicalSurface

Bases: SlidingSurface

Hierarchical sliding surface for multi-DOF underactuated systems.

.. math:: s_1 &= \dot{e}_a + c_1\,e_a \qquad \text{(actuated subsystem)} \ s_2 &= \dot{e}_u + c_2\,e_u \qquad \text{(unactuated subsystem)} \ S &= s_1 + \lambda\,s_2 \qquad \text{(hierarchical combination)}

A single control input stabilizes both subsystems through the dynamic coupling. The weight lambda controls the relative priority:

  • lambda = 0: only the actuated DOF is controlled.
  • lambda >> 1: the controller prioritizes the unactuated DOF.
Parameters

c1 : float Actuated subsystem surface slope (positive). c2 : float Unactuated subsystem surface slope (positive). lam : float Coupling weight (non-negative).

Source code in opensmc/surfaces/hierarchical.py
class HierarchicalSurface(SlidingSurface):
    r"""Hierarchical sliding surface for multi-DOF underactuated systems.

    .. math::
        s_1 &= \dot{e}_a + c_1\,e_a \qquad \text{(actuated subsystem)} \\
        s_2 &= \dot{e}_u + c_2\,e_u \qquad \text{(unactuated subsystem)} \\
        S   &= s_1 + \lambda\,s_2   \qquad \text{(hierarchical combination)}

    A single control input stabilizes both subsystems through the
    dynamic coupling. The weight lambda controls the relative priority:

    - lambda = 0: only the actuated DOF is controlled.
    - lambda >> 1: the controller prioritizes the unactuated DOF.

    Parameters
    ----------
    c1 : float
        Actuated subsystem surface slope (positive).
    c2 : float
        Unactuated subsystem surface slope (positive).
    lam : float
        Coupling weight (non-negative).
    """

    def __init__(self, c1=10.0, c2=10.0, lam=1.0):
        if c1 <= 0:
            raise ValueError(f"c1 must be positive, got {c1}")
        if c2 <= 0:
            raise ValueError(f"c2 must be positive, got {c2}")
        if lam < 0:
            raise ValueError(f"lam must be non-negative, got {lam}")
        self.c1 = c1
        self.c2 = c2
        self.lam = lam

    def compute(self, e, edot, t=0.0, **kwargs):
        r"""Compute the hierarchical sliding variable S = s1 + lambda * s2.

        For underactuated systems, the errors must be provided as
        keyword arguments:

        Parameters
        ----------
        e : float
            Actuated subsystem error (e_a). Ignored if e_a is in kwargs.
        edot : float
            Actuated subsystem error derivative. Ignored if edot_a is in kwargs.
        e_a : float, optional
            Actuated subsystem error (alternative to positional ``e``).
        edot_a : float, optional
            Actuated error derivative (alternative to positional ``edot``).
        e_u : float
            Unactuated subsystem error (required in kwargs).
        edot_u : float
            Unactuated error derivative (required in kwargs).

        Returns
        -------
        S : float
            Hierarchical sliding variable.
        """
        e_a = kwargs.get('e_a', e)
        edot_a = kwargs.get('edot_a', edot)
        e_u = kwargs.get('e_u', 0.0)
        edot_u = kwargs.get('edot_u', 0.0)

        s1 = edot_a + self.c1 * e_a
        s2 = edot_u + self.c2 * e_u
        return s1 + self.lam * s2
Functions
compute(e, edot, t=0.0, **kwargs)

Compute the hierarchical sliding variable S = s1 + lambda * s2.

For underactuated systems, the errors must be provided as keyword arguments:

Parameters

e : float Actuated subsystem error (e_a). Ignored if e_a is in kwargs. edot : float Actuated subsystem error derivative. Ignored if edot_a is in kwargs. e_a : float, optional Actuated subsystem error (alternative to positional e). edot_a : float, optional Actuated error derivative (alternative to positional edot). e_u : float Unactuated subsystem error (required in kwargs). edot_u : float Unactuated error derivative (required in kwargs).

Returns

S : float Hierarchical sliding variable.

Source code in opensmc/surfaces/hierarchical.py
def compute(self, e, edot, t=0.0, **kwargs):
    r"""Compute the hierarchical sliding variable S = s1 + lambda * s2.

    For underactuated systems, the errors must be provided as
    keyword arguments:

    Parameters
    ----------
    e : float
        Actuated subsystem error (e_a). Ignored if e_a is in kwargs.
    edot : float
        Actuated subsystem error derivative. Ignored if edot_a is in kwargs.
    e_a : float, optional
        Actuated subsystem error (alternative to positional ``e``).
    edot_a : float, optional
        Actuated error derivative (alternative to positional ``edot``).
    e_u : float
        Unactuated subsystem error (required in kwargs).
    edot_u : float
        Unactuated error derivative (required in kwargs).

    Returns
    -------
    S : float
        Hierarchical sliding variable.
    """
    e_a = kwargs.get('e_a', e)
    edot_a = kwargs.get('edot_a', edot)
    e_u = kwargs.get('e_u', 0.0)
    edot_u = kwargs.get('edot_u', 0.0)

    s1 = edot_a + self.c1 * e_a
    s2 = edot_u + self.c2 * e_u
    return s1 + self.lam * s2

IntegralSlidingSurface

Bases: SlidingSurface

Integral sliding surface (Utkin, 1996).

Utkin formulation:

.. math:: s(t) = G\bigl[x(t) - x(0)\bigr] - \int_0^t G\bigl(A\,x(\tau) + B\,u_{\mathrm{nom}}(\tau) \bigr)\,d\tau

Simplified scalar formulation (used here):

.. math:: s = c\,(e - e_0) - z, \quad \dot{z} = c\,\dot{e}_{\mathrm{nom}}

where e_0 is the initial error and z accumulates the nominal dynamics.

Key property: s(0) = 0 by construction -- no reaching phase.

For the common case of a second-order plant with nominal feedback u_nom = -K*x, the integral compensator evolves as:

.. math:: s = C \cdot (x_{\mathrm{err}} - E), \quad \dot{E} = (A - BK)\,x_{\mathrm{err}}, \quad E(0) = 0

Parameters

c : float Surface slope (positive). dt : float Integration time step for accumulating the integral.

Source code in opensmc/surfaces/integral_sliding.py
class IntegralSlidingSurface(SlidingSurface):
    r"""Integral sliding surface (Utkin, 1996).

    Utkin formulation:

    .. math::
        s(t) = G\bigl[x(t) - x(0)\bigr]
               - \int_0^t G\bigl(A\,x(\tau) + B\,u_{\mathrm{nom}}(\tau)
               \bigr)\,d\tau

    Simplified scalar formulation (used here):

    .. math::
        s = c\,(e - e_0) - z, \quad \dot{z} = c\,\dot{e}_{\mathrm{nom}}

    where e_0 is the initial error and z accumulates the nominal
    dynamics.

    **Key property:** s(0) = 0 by construction -- no reaching phase.

    For the common case of a second-order plant with nominal feedback
    u_nom = -K*x, the integral compensator evolves as:

    .. math::
        s = C \cdot (x_{\mathrm{err}} - E), \quad
        \dot{E} = (A - BK)\,x_{\mathrm{err}}, \quad E(0) = 0

    Parameters
    ----------
    c : float
        Surface slope (positive).
    dt : float
        Integration time step for accumulating the integral.
    """

    def __init__(self, c=10.0, dt=1e-4):
        if c <= 0:
            raise ValueError(f"Surface slope c must be positive, got {c}")
        self.c = c
        self.dt = dt
        self._e0 = None
        self._edot0 = None
        self._integral = 0.0
        self._initialized = False

    def compute(self, e, edot, t=0.0, **kwargs):
        r"""Compute the integral sliding variable.

        On first call, stores e(0) and edot(0) so that s(0) = 0.
        Subsequent calls accumulate the nominal dynamics integral.

        .. math::
            s(t) = (\dot{e} + c\,e) - (\dot{e}_0 + c\,e_0)\,
                   \exp(-\alpha\,t)

        This implementation uses the Utkin approach: s(0) = 0 exactly.

        Parameters
        ----------
        e : float
            Tracking error.
        edot : float
            Error derivative.
        t : float
            Current time.

        Returns
        -------
        s : float
            Sliding variable (zero at t = 0).
        """
        if not self._initialized:
            self._e0 = e
            self._edot0 = edot
            self._s0 = edot + self.c * e  # nominal surface value at t=0
            self._integral = 0.0
            self._initialized = True

        s_nominal = edot + self.c * e
        s_initial = self._edot0 + self.c * self._e0

        # Utkin formulation: subtract the initial condition contribution
        # s = (edot + c*e) - (edot0 + c*e0) + integral_correction
        # Simplified: s = G*(x - x0) - integral
        # which equals (edot + c*e) - (edot0 + c*e0) at t=0 => 0
        return s_nominal - s_initial + self._integral

    def reset(self):
        """Reset the integral state and initialization flag."""
        self._e0 = None
        self._edot0 = None
        self._integral = 0.0
        self._initialized = False
Functions
compute(e, edot, t=0.0, **kwargs)

Compute the integral sliding variable.

On first call, stores e(0) and edot(0) so that s(0) = 0. Subsequent calls accumulate the nominal dynamics integral.

.. math:: s(t) = (\dot{e} + c\,e) - (\dot{e}_0 + c\,e_0)\, \exp(-\alpha\,t)

This implementation uses the Utkin approach: s(0) = 0 exactly.

Parameters

e : float Tracking error. edot : float Error derivative. t : float Current time.

Returns

s : float Sliding variable (zero at t = 0).

Source code in opensmc/surfaces/integral_sliding.py
def compute(self, e, edot, t=0.0, **kwargs):
    r"""Compute the integral sliding variable.

    On first call, stores e(0) and edot(0) so that s(0) = 0.
    Subsequent calls accumulate the nominal dynamics integral.

    .. math::
        s(t) = (\dot{e} + c\,e) - (\dot{e}_0 + c\,e_0)\,
               \exp(-\alpha\,t)

    This implementation uses the Utkin approach: s(0) = 0 exactly.

    Parameters
    ----------
    e : float
        Tracking error.
    edot : float
        Error derivative.
    t : float
        Current time.

    Returns
    -------
    s : float
        Sliding variable (zero at t = 0).
    """
    if not self._initialized:
        self._e0 = e
        self._edot0 = edot
        self._s0 = edot + self.c * e  # nominal surface value at t=0
        self._integral = 0.0
        self._initialized = True

    s_nominal = edot + self.c * e
    s_initial = self._edot0 + self.c * self._e0

    # Utkin formulation: subtract the initial condition contribution
    # s = (edot + c*e) - (edot0 + c*e0) + integral_correction
    # Simplified: s = G*(x - x0) - integral
    # which equals (edot + c*e) - (edot0 + c*e0) at t=0 => 0
    return s_nominal - s_initial + self._integral
reset()

Reset the integral state and initialization flag.

Source code in opensmc/surfaces/integral_sliding.py
def reset(self):
    """Reset the integral state and initialization flag."""
    self._e0 = None
    self._edot0 = None
    self._integral = 0.0
    self._initialized = False

IntegralTerminalSurface

Bases: SlidingSurface

Integral terminal sliding surface.

.. math:: s = \dot{e} + c_1\,e + c_2 \int_0^t |e(\tau)|^{p/q} \,\mathrm{sign}(e(\tau))\,d\tau

Combines three mechanisms: - Linear term (c1 * e): fast convergence far from origin. - Terminal integral term: finite-time convergence via fractional power p/q < 1. - Integral action: eliminates steady-state error under constant disturbance.

Parameters

c1 : float Linear gain (positive). c2 : float Integral terminal gain (positive). p : int Numerator of fractional exponent (odd positive integer, p < q). q : int Denominator of fractional exponent (odd positive integer). dt : float Integration time step for accumulating the integral term.

Source code in opensmc/surfaces/integral_terminal.py
class IntegralTerminalSurface(SlidingSurface):
    r"""Integral terminal sliding surface.

    .. math::
        s = \dot{e} + c_1\,e + c_2 \int_0^t |e(\tau)|^{p/q}
            \,\mathrm{sign}(e(\tau))\,d\tau

    Combines three mechanisms:
    - Linear term (c1 * e): fast convergence far from origin.
    - Terminal integral term: finite-time convergence via fractional
      power p/q < 1.
    - Integral action: eliminates steady-state error under constant
      disturbance.

    Parameters
    ----------
    c1 : float
        Linear gain (positive).
    c2 : float
        Integral terminal gain (positive).
    p : int
        Numerator of fractional exponent (odd positive integer, p < q).
    q : int
        Denominator of fractional exponent (odd positive integer).
    dt : float
        Integration time step for accumulating the integral term.
    """

    def __init__(self, c1=10.0, c2=5.0, p=5, q=7, dt=1e-4):
        if c1 <= 0:
            raise ValueError(f"c1 must be positive, got {c1}")
        if c2 <= 0:
            raise ValueError(f"c2 must be positive, got {c2}")
        if p >= q:
            raise ValueError(f"Require p < q, got p={p}, q={q}")
        if p % 2 == 0 or q % 2 == 0:
            raise ValueError(f"p and q must be odd, got p={p}, q={q}")
        self.c1 = c1
        self.c2 = c2
        self.p = p
        self.q = q
        self.dt = dt
        self._integral = 0.0

    def compute(self, e, edot, t=0.0, **kwargs):
        r"""Compute s = edot + c1*e + c2*integral(|e|^{p/q}*sign(e) dt).

        The integral is accumulated internally using Euler integration
        at each call.
        """
        frac_term = np.abs(e) ** (self.p / self.q) * np.sign(e)
        self._integral += frac_term * self.dt
        return edot + self.c1 * e + self.c2 * self._integral

    def reset(self):
        """Reset the accumulated integral state to zero."""
        self._integral = 0.0
Functions
compute(e, edot, t=0.0, **kwargs)

Compute s = edot + c1e + c2integral(|e|^{p/q}*sign(e) dt).

The integral is accumulated internally using Euler integration at each call.

Source code in opensmc/surfaces/integral_terminal.py
def compute(self, e, edot, t=0.0, **kwargs):
    r"""Compute s = edot + c1*e + c2*integral(|e|^{p/q}*sign(e) dt).

    The integral is accumulated internally using Euler integration
    at each call.
    """
    frac_term = np.abs(e) ** (self.p / self.q) * np.sign(e)
    self._integral += frac_term * self.dt
    return edot + self.c1 * e + self.c2 * self._integral
reset()

Reset the accumulated integral state to zero.

Source code in opensmc/surfaces/integral_terminal.py
def reset(self):
    """Reset the accumulated integral state to zero."""
    self._integral = 0.0

LinearSurface

Bases: SlidingSurface

Classical linear sliding surface.

.. math:: s = \dot{e} + c \, e

Parameters

c : float Surface slope (must be positive). Controls the convergence rate on the sliding manifold: e(t) = e(t_r) * exp(-c*(t - t_r)).

Source code in opensmc/surfaces/linear.py
class LinearSurface(SlidingSurface):
    r"""Classical linear sliding surface.

    .. math::
        s = \dot{e} + c \, e

    Parameters
    ----------
    c : float
        Surface slope (must be positive). Controls the convergence
        rate on the sliding manifold: e(t) = e(t_r) * exp(-c*(t - t_r)).
    """

    def __init__(self, c=10.0):
        if c <= 0:
            raise ValueError(f"Surface slope c must be positive, got {c}")
        self.c = c

    def compute(self, e, edot, t=0.0, **kwargs):
        """Compute s = edot + c * e."""
        return edot + self.c * e
Functions
compute(e, edot, t=0.0, **kwargs)

Compute s = edot + c * e.

Source code in opensmc/surfaces/linear.py
def compute(self, e, edot, t=0.0, **kwargs):
    """Compute s = edot + c * e."""
    return edot + self.c * e

NonlinearDampingSurface

Bases: SlidingSurface

Nonlinear damping sliding surface.

.. math:: s = \dot{e} + \bigl(c + \Psi(y)\bigr)\,e

The nonlinear function Psi(y) adapts the effective slope c_eff = c + Psi(y) based on the output y, reducing overshoot compared to a fixed linear surface.

Two Psi types are supported:

Gaussian (Eq. 2.8):

.. math:: \Psi(y) = -\beta\,\exp(-\tilde{k}\,y^2)

  • At y = 0 (far from setpoint): Psi = -beta, c_eff = c - beta (gentle start).
  • At y = y_ref: Psi ~ 0, c_eff ~ c (full slope, fast final convergence).

Exponential (Eq. 2.7, adapted for tracking):

.. math:: \Psi(y) = \frac{-\beta}{1 - e^{-1}} \left(\exp!\bigl(-(1 - r^2)\bigr) - e^{-1}\right), \quad r = y / y_{\mathrm{ref}}

  • At y = 0: Psi ~ 0, c_eff = c (fast approach).
  • At y = y_ref: Psi = -beta, c_eff = c - beta (gentle arrival).
Parameters

c : float Base surface slope (positive, must satisfy c > beta for c_eff > 0). beta : float Damping amplitude (positive, beta < c). psi_type : str Either 'gaussian' or 'exponential'. k_tilde : float Width parameter for Gaussian Psi (positive). y_ref : float Reference setpoint for exponential Psi.

Source code in opensmc/surfaces/nonlinear_damping.py
class NonlinearDampingSurface(SlidingSurface):
    r"""Nonlinear damping sliding surface.

    .. math::
        s = \dot{e} + \bigl(c + \Psi(y)\bigr)\,e

    The nonlinear function Psi(y) adapts the effective slope c_eff = c + Psi(y)
    based on the output y, reducing overshoot compared to a fixed linear surface.

    Two Psi types are supported:

    **Gaussian** (Eq. 2.8):

    .. math::
        \Psi(y) = -\beta\,\exp(-\tilde{k}\,y^2)

    - At y = 0 (far from setpoint): Psi = -beta, c_eff = c - beta (gentle start).
    - At y = y_ref: Psi ~ 0, c_eff ~ c (full slope, fast final convergence).

    **Exponential** (Eq. 2.7, adapted for tracking):

    .. math::
        \Psi(y) = \frac{-\beta}{1 - e^{-1}}
                  \left(\exp\!\bigl(-(1 - r^2)\bigr) - e^{-1}\right),
        \quad r = y / y_{\mathrm{ref}}

    - At y = 0: Psi ~ 0, c_eff = c (fast approach).
    - At y = y_ref: Psi = -beta, c_eff = c - beta (gentle arrival).

    Parameters
    ----------
    c : float
        Base surface slope (positive, must satisfy c > beta for c_eff > 0).
    beta : float
        Damping amplitude (positive, beta < c).
    psi_type : str
        Either ``'gaussian'`` or ``'exponential'``.
    k_tilde : float
        Width parameter for Gaussian Psi (positive).
    y_ref : float
        Reference setpoint for exponential Psi.
    """

    def __init__(self, c=10.0, beta=7.9, psi_type='gaussian',
                 k_tilde=1.0, y_ref=1.0):
        if c <= 0:
            raise ValueError(f"c must be positive, got {c}")
        if beta <= 0:
            raise ValueError(f"beta must be positive, got {beta}")
        if beta >= c:
            raise ValueError(f"Require beta < c for positive c_eff, got beta={beta}, c={c}")
        if psi_type not in ('gaussian', 'exponential'):
            raise ValueError(f"psi_type must be 'gaussian' or 'exponential', got '{psi_type}'")
        self.c = c
        self.beta = beta
        self.psi_type = psi_type
        self.k_tilde = k_tilde
        self.y_ref = y_ref

    def _psi(self, y):
        """Compute the nonlinear damping function Psi(y)."""
        if self.psi_type == 'gaussian':
            return -self.beta * np.exp(-self.k_tilde * y ** 2)
        else:  # exponential
            if abs(self.y_ref) < 1e-10:
                return 0.0
            r = np.clip(y / self.y_ref, 0.0, 1.0)
            exponent = -(1.0 - r ** 2)
            return (-self.beta / (1.0 - np.exp(-1.0))
                    * (np.exp(exponent) - np.exp(-1.0)))

    def compute(self, e, edot, t=0.0, **kwargs):
        r"""Compute s = edot + (c + Psi(y)) * e.

        Parameters
        ----------
        e : float
            Tracking error.
        edot : float
            Error derivative.
        y : float, optional
            Current output value for computing Psi. If not provided,
            defaults to 0.0 (which gives Psi at the initial condition).

        Returns
        -------
        s : float
            Sliding variable.
        """
        y = kwargs.get('y', 0.0)
        c_eff = self.c + self._psi(y)
        return edot + c_eff * e
Functions
compute(e, edot, t=0.0, **kwargs)

Compute s = edot + (c + Psi(y)) * e.

Parameters

e : float Tracking error. edot : float Error derivative. y : float, optional Current output value for computing Psi. If not provided, defaults to 0.0 (which gives Psi at the initial condition).

Returns

s : float Sliding variable.

Source code in opensmc/surfaces/nonlinear_damping.py
def compute(self, e, edot, t=0.0, **kwargs):
    r"""Compute s = edot + (c + Psi(y)) * e.

    Parameters
    ----------
    e : float
        Tracking error.
    edot : float
        Error derivative.
    y : float, optional
        Current output value for computing Psi. If not provided,
        defaults to 0.0 (which gives Psi at the initial condition).

    Returns
    -------
    s : float
        Sliding variable.
    """
    y = kwargs.get('y', 0.0)
    c_eff = self.c + self._psi(y)
    return edot + c_eff * e

NonsingularTerminalSurface

Bases: SlidingSurface

Nonsingular terminal sliding surface (Yu & Zhihong, 2002).

.. math:: s = e + \frac{1}{\beta}\,|\dot{e}|^{q/p}\,\mathrm{sign}(\dot{e})

Avoids the singularity of the standard terminal surface by placing the fractional power on edot instead of e. Note q/p > 1 so the exponent is greater than one.

Parameters

beta : float Gain (positive). p : int Numerator (odd positive integer, p < q). q : int Denominator (odd positive integer).

Source code in opensmc/surfaces/terminal.py
class NonsingularTerminalSurface(SlidingSurface):
    r"""Nonsingular terminal sliding surface (Yu & Zhihong, 2002).

    .. math::
        s = e + \frac{1}{\beta}\,|\dot{e}|^{q/p}\,\mathrm{sign}(\dot{e})

    Avoids the singularity of the standard terminal surface by placing
    the fractional power on edot instead of e. Note q/p > 1 so the
    exponent is greater than one.

    Parameters
    ----------
    beta : float
        Gain (positive).
    p : int
        Numerator (odd positive integer, p < q).
    q : int
        Denominator (odd positive integer).
    """

    def __init__(self, beta=10.0, p=5, q=7):
        if beta <= 0:
            raise ValueError(f"beta must be positive, got {beta}")
        if p >= q:
            raise ValueError(f"Require p < q, got p={p}, q={q}")
        if p % 2 == 0 or q % 2 == 0:
            raise ValueError(f"p and q must be odd, got p={p}, q={q}")
        self.beta = beta
        self.p = p
        self.q = q

    def compute(self, e, edot, t=0.0, **kwargs):
        r"""Compute s = e + (1/beta) * |edot|^{q/p} * sign(edot)."""
        return e + (1.0 / self.beta) * _frac_power(edot, self.q, self.p)
Functions
compute(e, edot, t=0.0, **kwargs)

Compute s = e + (1/beta) * |edot|^{q/p} * sign(edot).

Source code in opensmc/surfaces/terminal.py
def compute(self, e, edot, t=0.0, **kwargs):
    r"""Compute s = e + (1/beta) * |edot|^{q/p} * sign(edot)."""
    return e + (1.0 / self.beta) * _frac_power(edot, self.q, self.p)

PIDSurface

Bases: SlidingSurface

PID-type sliding surface.

.. math:: s = \alpha\,\dot{e} + \beta\,e + \gamma \int_0^t e(\tau)\,d\tau

Three-term surface combining derivative, proportional, and integral action:

  • alpha (derivative): provides damping.
  • beta (proportional): controls convergence rate.
  • gamma (integral): eliminates steady-state error under constant disturbance.

When gamma = 0, this reduces to the classical linear surface s = alpha * edot + beta * e.

Designed for second-order SMC where the controller computes udot (control derivative), making the actual control u continuous (no chattering).

Parameters

alpha : float Derivative coefficient (positive). beta : float Proportional coefficient (positive). gamma : float Integral coefficient (non-negative). Set to 0 to recover the classical linear surface. dt : float Integration time step for the integral term.

Source code in opensmc/surfaces/pid.py
class PIDSurface(SlidingSurface):
    r"""PID-type sliding surface.

    .. math::
        s = \alpha\,\dot{e} + \beta\,e + \gamma \int_0^t e(\tau)\,d\tau

    Three-term surface combining derivative, proportional, and integral
    action:

    - **alpha** (derivative): provides damping.
    - **beta** (proportional): controls convergence rate.
    - **gamma** (integral): eliminates steady-state error under constant
      disturbance.

    When gamma = 0, this reduces to the classical linear surface
    s = alpha * edot + beta * e.

    Designed for second-order SMC where the controller computes udot
    (control derivative), making the actual control u continuous (no
    chattering).

    Parameters
    ----------
    alpha : float
        Derivative coefficient (positive).
    beta : float
        Proportional coefficient (positive).
    gamma : float
        Integral coefficient (non-negative). Set to 0 to recover
        the classical linear surface.
    dt : float
        Integration time step for the integral term.
    """

    def __init__(self, alpha=1.0, beta=10.0, gamma=1.0, dt=1e-4):
        if alpha <= 0:
            raise ValueError(f"alpha must be positive, got {alpha}")
        if beta <= 0:
            raise ValueError(f"beta must be positive, got {beta}")
        if gamma < 0:
            raise ValueError(f"gamma must be non-negative, got {gamma}")
        self.alpha = alpha
        self.beta = beta
        self.gamma = gamma
        self.dt = dt
        self._eint = 0.0

    def compute(self, e, edot, t=0.0, **kwargs):
        r"""Compute s = alpha * edot + beta * e + gamma * eint.

        The error integral is accumulated internally via Euler integration
        at each call.
        """
        self._eint += e * self.dt
        return self.alpha * edot + self.beta * e + self.gamma * self._eint

    def reset(self):
        """Reset the accumulated error integral to zero."""
        self._eint = 0.0
Functions
compute(e, edot, t=0.0, **kwargs)

Compute s = alpha * edot + beta * e + gamma * eint.

The error integral is accumulated internally via Euler integration at each call.

Source code in opensmc/surfaces/pid.py
def compute(self, e, edot, t=0.0, **kwargs):
    r"""Compute s = alpha * edot + beta * e + gamma * eint.

    The error integral is accumulated internally via Euler integration
    at each call.
    """
    self._eint += e * self.dt
    return self.alpha * edot + self.beta * e + self.gamma * self._eint
reset()

Reset the accumulated error integral to zero.

Source code in opensmc/surfaces/pid.py
def reset(self):
    """Reset the accumulated error integral to zero."""
    self._eint = 0.0

PredefinedTimeSurface

Bases: SlidingSurface

Predefined-time sliding surface.

For t < Tc:

.. math:: c(t) = \frac{\pi}{2\,T_c} \cdot \frac{1}{\cos!\left(\frac{\pi}{2}\,\frac{t}{T_c}\right)}

.. math:: s(t) = \dot{e} + c(t)\,e

For t >= Tc:

.. math:: s(t) = \dot{e} + c_\infty\,e \quad \text{(standard linear surface)}

Key property: The time-varying gain c(t) grows to infinity as t -> Tc, forcing e(t) -> 0 before the user-specified deadline Tc, regardless of initial conditions. After Tc, the surface reverts to a standard linear surface for regulation.

The gain derivative (needed for the controller) is:

.. math:: \dot{c}(t) = \frac{\pi^2}{4\,T_c^2} \cdot \frac{\sin!\left(\frac{\pi}{2}\,\frac{t}{T_c}\right)} {\cos^2!\left(\frac{\pi}{2}\,\frac{t}{T_c}\right)}

Parameters

Tc : float Predefined convergence time (positive). Error will reach zero before this time. c_inf : float Post-convergence surface slope (positive). Used for t >= Tc.

Source code in opensmc/surfaces/predefined_time.py
class PredefinedTimeSurface(SlidingSurface):
    r"""Predefined-time sliding surface.

    For t < Tc:

    .. math::
        c(t) = \frac{\pi}{2\,T_c}
               \cdot \frac{1}{\cos\!\left(\frac{\pi}{2}\,\frac{t}{T_c}\right)}

    .. math::
        s(t) = \dot{e} + c(t)\,e

    For t >= Tc:

    .. math::
        s(t) = \dot{e} + c_\infty\,e \quad \text{(standard linear surface)}

    **Key property:** The time-varying gain c(t) grows to infinity as
    t -> Tc, forcing e(t) -> 0 before the user-specified deadline Tc,
    regardless of initial conditions. After Tc, the surface reverts to
    a standard linear surface for regulation.

    The gain derivative (needed for the controller) is:

    .. math::
        \dot{c}(t) = \frac{\pi^2}{4\,T_c^2}
                     \cdot \frac{\sin\!\left(\frac{\pi}{2}\,\frac{t}{T_c}\right)}
                          {\cos^2\!\left(\frac{\pi}{2}\,\frac{t}{T_c}\right)}

    Parameters
    ----------
    Tc : float
        Predefined convergence time (positive). Error will reach zero
        before this time.
    c_inf : float
        Post-convergence surface slope (positive). Used for t >= Tc.
    """

    def __init__(self, Tc=2.0, c_inf=10.0):
        if Tc <= 0:
            raise ValueError(f"Tc must be positive, got {Tc}")
        if c_inf <= 0:
            raise ValueError(f"c_inf must be positive, got {c_inf}")
        self.Tc = Tc
        self.c_inf = c_inf

    def gain(self, t):
        """Compute the time-varying gain c(t).

        Returns c(t) for t < Tc, or c_inf for t >= Tc.
        Clamps t/Tc to 0.999 to avoid division by zero at t = Tc.
        """
        if t >= self.Tc:
            return self.c_inf
        ratio = min(t / self.Tc, 0.999)
        return (np.pi / (2.0 * self.Tc)) / np.cos(np.pi / 2.0 * ratio)

    def gain_dot(self, t):
        """Compute the time derivative of c(t).

        Returns dc/dt for t < Tc, or 0.0 for t >= Tc.
        """
        if t >= self.Tc:
            return 0.0
        ratio = min(t / self.Tc, 0.999)
        s = np.sin(np.pi / 2.0 * ratio)
        c = np.cos(np.pi / 2.0 * ratio)
        return (np.pi ** 2 / (4.0 * self.Tc ** 2)) * s / (c ** 2)

    def compute(self, e, edot, t=0.0, **kwargs):
        r"""Compute s = edot + c(t) * e.

        Parameters
        ----------
        e : float
            Tracking error.
        edot : float
            Error derivative.
        t : float
            Current time (required for the time-varying gain).

        Returns
        -------
        s : float
            Sliding variable.
        """
        ct = self.gain(t)
        return edot + ct * e
Functions
compute(e, edot, t=0.0, **kwargs)

Compute s = edot + c(t) * e.

Parameters

e : float Tracking error. edot : float Error derivative. t : float Current time (required for the time-varying gain).

Returns

s : float Sliding variable.

Source code in opensmc/surfaces/predefined_time.py
def compute(self, e, edot, t=0.0, **kwargs):
    r"""Compute s = edot + c(t) * e.

    Parameters
    ----------
    e : float
        Tracking error.
    edot : float
        Error derivative.
    t : float
        Current time (required for the time-varying gain).

    Returns
    -------
    s : float
        Sliding variable.
    """
    ct = self.gain(t)
    return edot + ct * e
gain(t)

Compute the time-varying gain c(t).

Returns c(t) for t < Tc, or c_inf for t >= Tc. Clamps t/Tc to 0.999 to avoid division by zero at t = Tc.

Source code in opensmc/surfaces/predefined_time.py
def gain(self, t):
    """Compute the time-varying gain c(t).

    Returns c(t) for t < Tc, or c_inf for t >= Tc.
    Clamps t/Tc to 0.999 to avoid division by zero at t = Tc.
    """
    if t >= self.Tc:
        return self.c_inf
    ratio = min(t / self.Tc, 0.999)
    return (np.pi / (2.0 * self.Tc)) / np.cos(np.pi / 2.0 * ratio)
gain_dot(t)

Compute the time derivative of c(t).

Returns dc/dt for t < Tc, or 0.0 for t >= Tc.

Source code in opensmc/surfaces/predefined_time.py
def gain_dot(self, t):
    """Compute the time derivative of c(t).

    Returns dc/dt for t < Tc, or 0.0 for t >= Tc.
    """
    if t >= self.Tc:
        return 0.0
    ratio = min(t / self.Tc, 0.999)
    s = np.sin(np.pi / 2.0 * ratio)
    c = np.cos(np.pi / 2.0 * ratio)
    return (np.pi ** 2 / (4.0 * self.Tc ** 2)) * s / (c ** 2)

RLDiscoveredSurface

Bases: SlidingSurface

Sliding surface learned by a reinforcement learning agent.

Wraps a trained Stable-Baselines3 model (or any callable) as a SlidingSurface compatible with all OpenSMC controllers.

Parameters

model : str or callable If str: path to a saved SB3 model (.zip). Loaded with PPO.load(). If callable: function(obs) -> action that computes the surface value. obs_fn : callable or None Optional function to map (e, edot, t) -> observation vector. Default: np.array([e, edot]). scale : float Scale factor applied to the RL output. Default 1.0.

Source code in opensmc/rl/rl_surface.py
class RLDiscoveredSurface(SlidingSurface):
    """Sliding surface learned by a reinforcement learning agent.

    Wraps a trained Stable-Baselines3 model (or any callable) as a
    SlidingSurface compatible with all OpenSMC controllers.

    Parameters
    ----------
    model : str or callable
        If str: path to a saved SB3 model (.zip). Loaded with PPO.load().
        If callable: function(obs) -> action that computes the surface value.
    obs_fn : callable or None
        Optional function to map (e, edot, t) -> observation vector.
        Default: np.array([e, edot]).
    scale : float
        Scale factor applied to the RL output. Default 1.0.
    """

    def __init__(self, model, obs_fn=None, scale=1.0):
        self.scale = scale
        self._obs_fn = obs_fn

        if isinstance(model, str):
            self._model = self._load_sb3(model)
            self._predict = self._sb3_predict
        elif callable(model):
            self._model = model
            self._predict = model
        else:
            raise TypeError(
                f"model must be a file path (str) or callable, got {type(model)}")

    @staticmethod
    def _load_sb3(path):
        """Load a Stable-Baselines3 model from file."""
        try:
            from stable_baselines3 import PPO
            return PPO.load(path)
        except ImportError:
            raise ImportError(
                "stable-baselines3 is required to load SB3 models. "
                "Install with: pip install opensmc[rl]")

    def _sb3_predict(self, obs):
        """Predict using SB3 model (deterministic)."""
        action, _ = self._model.predict(obs, deterministic=True)
        return action

    def _make_obs(self, e, edot, t):
        """Build observation vector from error state."""
        if self._obs_fn is not None:
            return self._obs_fn(e, edot, t)

        e_scalar = float(e) if np.ndim(e) == 0 else e
        edot_scalar = float(edot) if np.ndim(edot) == 0 else edot

        if np.ndim(e_scalar) == 0:
            return np.array([e_scalar, edot_scalar], dtype=np.float32)
        else:
            return np.concatenate([
                np.atleast_1d(e_scalar),
                np.atleast_1d(edot_scalar)
            ]).astype(np.float32)

    def compute(self, e, edot, t=0.0, **kwargs):
        """Compute sliding variable using the RL policy.

        Parameters
        ----------
        e : float or ndarray — tracking error
        edot : float or ndarray — error derivative
        t : float — time

        Returns
        -------
        s : float or ndarray — RL-generated sliding variable
        """
        obs = self._make_obs(e, edot, t)
        raw = self._predict(obs)
        s = float(np.ravel(raw)[0]) * self.scale
        return s

    def reset(self):
        """Reset (no-op for most RL models)."""
        pass

    def __repr__(self):
        return f"RLDiscoveredSurface(scale={self.scale})"
Functions
compute(e, edot, t=0.0, **kwargs)

Compute sliding variable using the RL policy.

Parameters

e : float or ndarray — tracking error edot : float or ndarray — error derivative t : float — time

Returns

s : float or ndarray — RL-generated sliding variable

Source code in opensmc/rl/rl_surface.py
def compute(self, e, edot, t=0.0, **kwargs):
    """Compute sliding variable using the RL policy.

    Parameters
    ----------
    e : float or ndarray — tracking error
    edot : float or ndarray — error derivative
    t : float — time

    Returns
    -------
    s : float or ndarray — RL-generated sliding variable
    """
    obs = self._make_obs(e, edot, t)
    raw = self._predict(obs)
    s = float(np.ravel(raw)[0]) * self.scale
    return s
reset()

Reset (no-op for most RL models).

Source code in opensmc/rl/rl_surface.py
def reset(self):
    """Reset (no-op for most RL models)."""
    pass

TerminalSurface

Bases: SlidingSurface

Terminal sliding surface (Zak, 1988).

.. math:: s = \dot{e} + \beta\,|e|^{p/q}\,\mathrm{sign}(e)

Provides finite-time convergence on the sliding manifold with convergence time:

.. math:: T_f = \frac{q}{\beta\,(q - p)}\,|e(0)|^{(q-p)/q}

.. warning:: This surface has a singularity at e = 0 in the equivalent control (the coefficient beta * (p/q) * |e|^{p/q - 1} diverges). Use :class:NonsingularTerminalSurface to avoid this issue.

Parameters

beta : float Gain (positive). p : int Numerator of the fractional exponent (odd positive integer, p < q). q : int Denominator of the fractional exponent (odd positive integer).

Source code in opensmc/surfaces/terminal.py
class TerminalSurface(SlidingSurface):
    r"""Terminal sliding surface (Zak, 1988).

    .. math::
        s = \dot{e} + \beta\,|e|^{p/q}\,\mathrm{sign}(e)

    Provides finite-time convergence on the sliding manifold with
    convergence time:

    .. math::
        T_f = \frac{q}{\beta\,(q - p)}\,|e(0)|^{(q-p)/q}

    .. warning::
        This surface has a singularity at e = 0 in the equivalent control
        (the coefficient beta * (p/q) * |e|^{p/q - 1} diverges). Use
        :class:`NonsingularTerminalSurface` to avoid this issue.

    Parameters
    ----------
    beta : float
        Gain (positive).
    p : int
        Numerator of the fractional exponent (odd positive integer, p < q).
    q : int
        Denominator of the fractional exponent (odd positive integer).
    """

    def __init__(self, beta=10.0, p=5, q=7):
        if beta <= 0:
            raise ValueError(f"beta must be positive, got {beta}")
        if p >= q:
            raise ValueError(f"Require p < q, got p={p}, q={q}")
        if p % 2 == 0 or q % 2 == 0:
            raise ValueError(f"p and q must be odd, got p={p}, q={q}")
        self.beta = beta
        self.p = p
        self.q = q

    def compute(self, e, edot, t=0.0, **kwargs):
        r"""Compute s = edot + beta * |e|^{p/q} * sign(e)."""
        return edot + self.beta * _frac_power(e, self.p, self.q)
Functions
compute(e, edot, t=0.0, **kwargs)

Compute s = edot + beta * |e|^{p/q} * sign(e).

Source code in opensmc/surfaces/terminal.py
def compute(self, e, edot, t=0.0, **kwargs):
    r"""Compute s = edot + beta * |e|^{p/q} * sign(e)."""
    return edot + self.beta * _frac_power(e, self.p, self.q)