systems.base.core.SymbolicSystemBase

systems.base.core.SymbolicSystemBase(*args, **kwargs)

Abstract base class for symbolic systems (time-domain agnostic).

Provides symbolic machinery for ANY symbolic system, whether continuous or discrete time. This class extracts the ~1,800 lines of common code that was previously duplicated between SymbolicDynamicalSystem and DiscreteSymbolicSystem.

Subclasses must: 1. Inherit from BOTH SymbolicSystemBase AND a time-domain base (ContinuousSystemBase or DiscreteSystemBase) 2. Implement the abstract define_system() method 3. Implement the abstract print_equations() method 4. Implement all methods from the time-domain base interface

Attributes

Name Type Description
state_vars List[sp.Symbol] State variables as SymPy symbols (e.g., [x, v, theta])
control_vars List[sp.Symbol] Control variables as SymPy symbols (e.g., [u1, u2])
output_vars List[sp.Symbol] Output variable names (optional)
parameters Dict[sp.Symbol, float] System parameters with Symbol keys (e.g., {m: 1.0, k: 10.0})
_f_sym sp.Matrix Symbolic dynamics expression (interpretation depends on subclass)
_h_sym Optional[sp.Matrix] Symbolic output expression (None = identity output)
order int System order (1 = first-order, 2 = second-order, etc.)
backend BackendManager Backend management component
equilibria EquilibriumHandler Equilibrium point management

Examples

Concrete system combining symbolic base with continuous interface:

>>> class LinearOscillator(SymbolicSystemBase, ContinuousSystemBase):
...     def define_system(self, k=1.0, c=0.1):
...         x, v = sp.symbols('x v', real=True)
...         u = sp.symbols('u', real=True)
...         k_sym, c_sym = sp.symbols('k c', positive=True)
...
...         self.state_vars = [x, v]
...         self.control_vars = [u]
...         self._f_sym = sp.Matrix([v, -k_sym*x - c_sym*v + u])
...         self.parameters = {k_sym: k, c_sym: c}
...         self.order = 1
...
...     def print_equations(self, simplify=True):
...         print("Continuous dynamics: dx/dt = f(x, u)")
...         # ... implementation
...
...     # Also implement ContinuousSystemBase interface
...     def __call__(self, x, u=None, t=0.0):
...         # ... implementation
...         pass
...
>>> system = LinearOscillator(k=2.0, c=0.5)
>>> system.nx  # Number of states
2
>>> system.parameters  # Numerical values
{k: 2.0, c: 0.5}

Methods

Name Description
add_equilibrium Add an equilibrium point with optional verification.
compile Pre-compile dynamics functions for specified backends.
define_system Define the symbolic system (must be implemented by subclasses).
get_backend_info Get comprehensive information about backend configuration and status.
get_config_dict Get system configuration as dictionary.
get_equilibrium Get equilibrium state and control in specified backend.
get_equilibrium_metadata Get metadata for equilibrium.
get_performance_stats Get performance statistics for system operations.
list_equilibria List all equilibrium names.
print_equations Print symbolic equations in human-readable format.
remove_equilibrium Remove an equilibrium point.
reset_caches Reset cached compiled functions for specified backends.
reset_performance_stats Reset all performance counters to zero.
save_config Save system configuration to JSON file.
set_default_backend Set default backend and optionally device for this system.
set_default_equilibrium Set default equilibrium for get operations without name.
setup_equilibria Optional hook to add equilibria after system initialization.
substitute_parameters Substitute numerical parameter values into symbolic expression.
to_device Set preferred device for PyTorch/JAX backends.
use_backend Temporarily switch to a different backend and/or device.

add_equilibrium

systems.base.core.SymbolicSystemBase.add_equilibrium(
    name,
    x_eq,
    u_eq,
    verify=True,
    tol=1e-06,
    **metadata,
)

Add an equilibrium point with optional verification.

Equilibrium points are states where the dynamics are zero (or identity for discrete systems). The system can have multiple equilibria (e.g., pendulum upright vs downward).

Parameters

Name Type Description Default
name str Unique name for this equilibrium (e.g., ‘origin’, ‘upright’, ‘inverted’) required
x_eq np.ndarray Equilibrium state (nx,) required
u_eq np.ndarray Equilibrium control (nu,) required
verify bool If True, verify that equilibrium condition holds (default: True) True
tol float Tolerance for verification (default: 1e-6) 1e-06
**metadata dict Additional metadata to store (e.g., stability=‘stable’) {}

Raises

Name Type Description
ValueError If dimensions don’t match system dimensions
UserWarning If verification fails (not actually an equilibrium)

Examples

>>> # Pendulum downward equilibrium
>>> system.add_equilibrium(
...     'downward',
...     x_eq=np.array([0.0, 0.0]),
...     u_eq=np.array([0.0]),
...     verify=True
... )
>>> # Inverted pendulum (with metadata)
>>> system.add_equilibrium(
...     'inverted',
...     x_eq=np.array([np.pi, 0.0]),
...     u_eq=np.array([0.0]),
...     stability='unstable',
...     notes='Requires active control'
... )
>>> # Get equilibrium back
>>> x_eq = system.equilibria.get_x('inverted')
>>> u_eq = system.equilibria.get_u('inverted')

Notes

  • Verification implementation depends on concrete subclass
  • If verification fails, a warning is issued but equilibrium is still added
  • Delegates to EquilibriumHandler.add() for storage and management
  • Concrete subclasses may override to provide custom verification

See Also

setup_equilibrium: Automatically add equilibria after system initialization get_equilibrium : Retrieve equilibrium in specified backend list_equilibria : List all equilibrium names set_default_equilibrium : Set default equilibrium remove_equilibrium : Remove an equilibrium

compile

systems.base.core.SymbolicSystemBase.compile(
    backends=None,
    verbose=False,
    **kwargs,
)

Pre-compile dynamics functions for specified backends.

Compilation happens lazily by default (on first use). This method allows eager compilation to reduce first-call latency and validate that code generation works correctly.

Parameters

Name Type Description Default
backends Optional[List[str]] List of backends to compile (‘numpy’, ‘torch’, ‘jax’). If None, compiles for all available backends. None
verbose bool If True, print compilation progress and timing False
**kwargs dict Backend-specific compilation options {}

Returns

Name Type Description
Dict[str, float] Compilation times per backend (seconds)

Examples

>>> # Compile for all available backends
>>> times = system.compile(verbose=True)
Compiling for numpy... 0.123s
Compiling for torch... 0.456s
Compiling for jax... 0.789s
>>> # Compile only for specific backends
>>> system.compile(backends=['numpy', 'torch'])
>>> # Chain with other operations
>>> system.compile().set_default_backend('torch')

Notes

  • Compilation is cached - subsequent calls are no-ops unless cache is cleared
  • JAX compilation includes JIT, which may take longer initially
  • PyTorch compilation is optional (not JIT by default)
  • NumPy always uses regular Python functions (fastest to “compile”)

See Also

reset_caches : Clear compiled function cache get_backend_info : Check compilation status

define_system

systems.base.core.SymbolicSystemBase.define_system(*args, **kwargs)

Define the symbolic system (must be implemented by subclasses).

This method must populate the following attributes:

Required Attributes:

  • self.state_vars: List[sp.Symbol] State variables (e.g., [x, y, theta]) Cannot be empty
  • self.control_vars: List[sp.Symbol] Control variables (e.g., [u1, u2]) Empty list for autonomous systems
  • self._f_sym: sp.Matrix Symbolic dynamics (column vector)
  • self.parameters: Dict[sp.Symbol, float] Parameter values with Symbol keys (NOT strings!)

Optional Attributes:

  • self.output_vars: List[sp.Symbol] Output variable names (optional)
  • self._h_sym: sp.Matrix Symbolic output function (None = identity)
  • self.order: int System order (default: 1)

Parameters

Name Type Description Default
*args tuple System-specific positional arguments (e.g., mass, length, damping) ()
**kwargs dict System-specific keyword arguments {}

Raises

Name Type Description
ValidationError If the defined system is invalid (checked after this method returns)

Notes

CRITICAL: self.parameters must use SymPy Symbol objects as keys!

Correct::

{m: 1.0, l: 0.5}

Incorrect::

{'m': 1.0, 'l': 0.5}  # Strings won't work!

System Order - Two Equivalent Formulations:

  1. First-Order State-Space Form (order=1):

    • State: x = [q, q̇] for a 2nd-order physical system
    • _f_sym returns ALL derivatives: [q̇, q̈]
    • Set: self.order = 1

    Example::

    self.state_vars = [theta, theta_dot]
    self._f_sym = sp.Matrix([
        theta_dot,                      # dθ/dt = θ̇
        -k*theta - c*theta_dot + u      # dθ̇/dt = θ̈
    ])
    self.order = 1
  2. Higher-Order Form (order=n):

    • State: x = [q, q̇] for a 2nd-order physical system
    • _f_sym returns ONLY highest derivative: q̈
    • Set: self.order = 2

    Example::

    self.state_vars = [theta, theta_dot]
    self._f_sym = sp.Matrix([
        -k*theta - c*theta_dot + u  # Only θ̈
    ])
    self.order = 2

Both formulations are mathematically equivalent! The framework handles state-space construction automatically during linearization.

When to use which:

  • Use order=1 (state-space) for: simpler code, explicit derivatives
  • Use order=n (higher-order) for: physics-focused definitions, cleaner dynamics

Validation rules:

  • For order=1: len(_f_sym) must equal nx
  • For order=n: len(_f_sym) must equal nq, and nx must be divisible by order

Examples

First-order system::

def define_system(self, a=1.0):
    x = sp.symbols('x')
    u = sp.symbols('u')
    a_sym = sp.symbols('a', real=True, positive=True)

    self.state_vars = [x]
    self.control_vars = [u]
    self._f_sym = sp.Matrix([-a_sym * x + u])
    self.parameters = {a_sym: a}
    self.order = 1

Second-order (state-space form)::

def define_system(self, m=1.0, k=10.0, c=0.5):
    q, q_dot = sp.symbols('q q_dot')
    u = sp.symbols('u')
    m_sym, k_sym, c_sym = sp.symbols('m k c', positive=True)

    # Return both derivatives explicitly
    self.state_vars = [q, q_dot]
    self.control_vars = [u]
    self._f_sym = sp.Matrix([
        q_dot,                                  # dq/dt
        (-k_sym*q - c_sym*q_dot + u)/m_sym     # dq̇/dt = q̈
    ])
    self.parameters = {m_sym: m, k_sym: k, c_sym: c}
    self.order = 1  # First-order state-space

Second-order (higher-order form)::

def define_system(self, m=1.0, k=10.0, c=0.5):
    q, q_dot = sp.symbols('q q_dot')
    u = sp.symbols('u')
    m_sym, k_sym, c_sym = sp.symbols('m k c', positive=True)

    # Return only acceleration
    q_ddot = (-k_sym*q - c_sym*q_dot + u)/m_sym

    self.state_vars = [q, q_dot]
    self.control_vars = [u]
    self._f_sym = sp.Matrix([q_ddot])  # Only highest derivative
    self.parameters = {m_sym: m, k_sym: k, c_sym: c}
    self.order = 2  # Second-order form

get_backend_info

systems.base.core.SymbolicSystemBase.get_backend_info()

Get comprehensive information about backend configuration and status.

Returns

Name Type Description
dict Dictionary containing: - ‘default_backend’: Current default backend - ‘preferred_device’: Current device setting - ‘available_backends’: List of installed backends - ‘compiled_backends’: List of backends with compiled functions - ‘torch_available’: Whether PyTorch is installed - ‘jax_available’: Whether JAX is installed - ‘numpy_version’: NumPy version string - ‘torch_version’: PyTorch version (or None) - ‘jax_version’: JAX version (or None) - ‘initialized’: Whether system is initialized

Examples

>>> info = system.get_backend_info()
>>> print(f"Default: {info['default_backend']}")
>>> print(f"Available: {info['available_backends']}")
>>> print(f"Compiled: {info['compiled_backends']}")

get_config_dict

systems.base.core.SymbolicSystemBase.get_config_dict()

Get system configuration as dictionary.

Returns

Name Type Description
Dict Configuration dictionary containing: - ‘class_name’: System class name - ‘state_vars’: State variable names - ‘control_vars’: Control variable names - ‘output_vars’: Output variable names - ‘parameters’: Parameter values (as dict) - ‘order’: System order - ‘nx’, ‘nu’, ‘ny’: Dimensions - ‘backend’: Default backend - ‘device’: Preferred device

Examples

>>> config = system.get_config_dict()
>>> config['nx']
2
>>> config['parameters']
{'m': 1.0, 'k': 10.0}

Notes

  • Useful for saving system configuration to file
  • Does not include compiled functions or cached data
  • Can be used with save_config() for persistence

See Also

save_config : Save configuration to JSON file

get_equilibrium

systems.base.core.SymbolicSystemBase.get_equilibrium(name=None, backend=None)

Get equilibrium state and control in specified backend.

Parameters

Name Type Description Default
name Optional[str] Equilibrium name (None = default) None
backend Optional[str] Backend for arrays (None = system default) None

Returns

Name Type Description
Tuple[ArrayLike, ArrayLike] (x_eq, u_eq) in requested backend

Examples

>>> x_eq, u_eq = system.get_equilibrium('inverted', backend='torch')
>>> x_eq, u_eq = system.get_equilibrium()  # Default equilibrium, default backend

get_equilibrium_metadata

systems.base.core.SymbolicSystemBase.get_equilibrium_metadata(name=None)

Get metadata for equilibrium.

Parameters

Name Type Description Default
name Optional[str] Equilibrium name (None = default) None

Returns

Name Type Description
Dict Metadata dictionary

Examples

>>> meta = system.get_equilibrium_metadata('inverted')
>>> print(meta['stability'])
'unstable'

get_performance_stats

systems.base.core.SymbolicSystemBase.get_performance_stats()

Get performance statistics for system operations.

Returns timing and call count information for key operations. Useful for profiling and optimization.

Returns

Name Type Description
Dict[str, float] Dictionary containing: - ‘forward_calls’: Number of forward dynamics calls - ‘forward_time’: Total time in forward dynamics (seconds) - ‘avg_forward_time’: Average time per forward call (seconds) - (Other stats depend on concrete subclass implementation)

Examples

>>> # Run some operations
>>> for _ in range(100):
...     dx = system(x, u)
...
>>> stats = system.get_performance_stats()
>>> print(f"Forward calls: {stats['forward_calls']}")
>>> print(f"Avg time: {stats['avg_forward_time']:.6f}s")

Notes

  • Statistics accumulate over system lifetime
  • Use reset_performance_stats() to clear counters
  • Timing includes overhead from backend detection/conversion
  • Concrete subclasses may add additional statistics

See Also

reset_performance_stats : Reset all performance counters

list_equilibria

systems.base.core.SymbolicSystemBase.list_equilibria()

List all equilibrium names.

Returns

Name Type Description
List[str] Names of all defined equilibria

Examples

>>> system.list_equilibria()
['origin', 'downward', 'inverted']

print_equations

systems.base.core.SymbolicSystemBase.print_equations(simplify=True)

Print symbolic equations in human-readable format.

This method is abstract because the notation differs between continuous and discrete systems: - Continuous: “dx/dt = f(x, u)” or “dθ/dt”, “dθ̇/dt” - Discrete: “x[k+1] = f(x[k], u[k])” or “θ[k+1]”, “θ̇[k+1]”

Subclasses must implement this with appropriate notation for their time-domain semantics.

Parameters

Name Type Description Default
simplify bool If True, simplify expressions before printing If False, print raw expressions True

Notes

Typical implementation should display: - System name - State and control variables - System order and dimensions - Dynamics equations with proper notation - Output equations (if defined)

Examples

Continuous implementation::

def print_equations(self, simplify=True):
    print("=" * 70)
    print(f"{self.__class__.__name__}")
    print("=" * 70)
    print(f"State Variables: {self.state_vars}")
    print(f"Control Variables: {self.control_vars}")
    print(f"System Order: {self.order}")
    print(f"Dimensions: nx={self.nx}, nu={self.nu}, ny={self.ny}")

    print("\nDynamics: dx/dt = f(x, u)")
    for var, expr in zip(self.state_vars, self._f_sym):
        expr_sub = self.substitute_parameters(expr)
        if simplify:
            expr_sub = sp.simplify(expr_sub)
        print(f"  d{var}/dt = {expr_sub}")

    if self._h_sym is not None:
        print("\nOutput: y = h(x)")
        for i, expr in enumerate(self._h_sym):
            expr_sub = self.substitute_parameters(expr)
            if simplify:
                expr_sub = sp.simplify(expr_sub)
            print(f"  y[{i}] = {expr_sub}")

    print("=" * 70)

Discrete implementation::

def print_equations(self, simplify=True):
    print("=" * 70)
    print(f"{self.__class__.__name__}")
    print("=" * 70)
    print(f"State Variables: {self.state_vars}")
    print(f"Control Variables: {self.control_vars}")
    print(f"System Order: {self.order}")
    print(f"Dimensions: nx={self.nx}, nu={self.nu}, ny={self.ny}")

    print("\nDynamics: x[k+1] = f(x[k], u[k])")
    for var, expr in zip(self.state_vars, self._f_sym):
        expr_sub = self.substitute_parameters(expr)
        if simplify:
            expr_sub = sp.simplify(expr_sub)
        print(f"  {var}[k+1] = {expr_sub}")

    if self._h_sym is not None:
        print("\nOutput: y[k] = h(x[k])")
        for i, expr in enumerate(self._h_sym):
            expr_sub = self.substitute_parameters(expr)
            if simplify:
                expr_sub = sp.simplify(expr_sub)
            print(f"  y[{i}] = {expr_sub}")

    print("=" * 70)

remove_equilibrium

systems.base.core.SymbolicSystemBase.remove_equilibrium(name)

Remove an equilibrium point.

Parameters

Name Type Description Default
name str Equilibrium name to remove required

Raises

Name Type Description
ValueError If trying to remove ‘origin’ or nonexistent equilibrium

Examples

>>> system.remove_equilibrium('test_point')

reset_caches

systems.base.core.SymbolicSystemBase.reset_caches(backends=None)

Reset cached compiled functions for specified backends.

Clears the code generation cache, forcing recompilation on next use. Useful when system parameters change or to free memory.

Parameters

Name Type Description Default
backends Optional[List[str]] List of backends to reset (‘numpy’, ‘torch’, ‘jax’). If None, resets all backends. None

Examples

>>> # Reset all cached functions
>>> system.reset_caches()
>>> # Reset only PyTorch cache
>>> system.reset_caches(['torch'])
>>> # After parameter update
>>> system.parameters[m] = 2.0  # Changed mass
>>> system.reset_caches()  # Force recompilation with new value

Notes

  • Does not affect the system definition (state_vars, _f_sym, etc.)
  • Only clears the compiled numerical functions
  • Next function call will trigger recompilation
  • Use sparingly - compilation has overhead

See Also

compile : Pre-compile functions _clear_backend_cache : Clear single backend (internal use)

reset_performance_stats

systems.base.core.SymbolicSystemBase.reset_performance_stats()

Reset all performance counters to zero.

Clears timing and call count statistics across all components.

Examples

>>> system.reset_performance_stats()
>>> stats = system.get_performance_stats()
>>> stats['forward_calls']
0

Notes

  • Resets counters in all components (DynamicsEvaluator, etc.)
  • Does not affect compilation cache or system definition
  • Concrete subclasses override to reset their component stats

save_config

systems.base.core.SymbolicSystemBase.save_config(filename)

Save system configuration to JSON file.

Parameters

Name Type Description Default
filename str Path to output file (will be created/overwritten) required

Examples

>>> system.save_config('pendulum_config.json')
>>> # Load config (manually)
>>> import json
>>> with open('pendulum_config.json', 'r') as f:
...     config = json.load(f)
>>> print(config['parameters'])

Notes

  • Saves only configuration, not compiled functions
  • Use get_config_dict() to get config without saving
  • JSON format enables easy sharing and version control

See Also

get_config_dict : Get configuration dictionary

set_default_backend

systems.base.core.SymbolicSystemBase.set_default_backend(backend, device=None)

Set default backend and optionally device for this system.

The default backend is used when backend=‘default’ is passed to methods, or when no backend is specified and conversion is needed.

Parameters

Name Type Description Default
backend str Backend name (‘numpy’, ‘torch’, or ‘jax’) required
device Optional[str] Device for GPU backends (‘cpu’, ‘cuda’, ‘cuda:0’, ‘gpu:0’, etc.) If None, device is not changed. None

Returns

Name Type Description
SymbolicSystemBase Self (for method chaining)

Raises

Name Type Description
ValueError If backend name is invalid
RuntimeError If backend is not available (not installed)

Examples

>>> system.set_default_backend('torch', device='cuda:0')
>>> system._default_backend
'torch'
>>> system._preferred_device
'cuda:0'

Method chaining:

>>> system.set_default_backend('jax').compile(verbose=True)

set_default_equilibrium

systems.base.core.SymbolicSystemBase.set_default_equilibrium(name)

Set default equilibrium for get operations without name.

Parameters

Name Type Description Default
name str Name of equilibrium to use as default required

Returns

Name Type Description
SymbolicSystemBase Self for method chaining

Examples

>>> system.set_default_equilibrium('inverted')
>>> x_eq = system.equilibria.get_x()  # Gets 'inverted' by default

Method chaining:

>>> system.set_default_equilibrium('upright').compile()

setup_equilibria

systems.base.core.SymbolicSystemBase.setup_equilibria()

Optional hook to add equilibria after system initialization.

This method is called automatically after the system is fully initialized if auto_add_equilibria=True (default).

Override this method in subclasses to add standard equilibria. Can access self.parameters for parameter-dependent equilibria.

Examples

Parameter-independent:

>>> def setup_equilibria(self):
...     self.equilibria.add('origin', np.zeros(self.nx), np.zeros(self.nu))

Parameter-dependent:

>>> def setup_equilibria(self):
...     g = self.parameters[self._g_sym]  # Access parameter value
...     x_eq = np.array([0, np.sqrt(g)])
...     self.equilibria.add('special', x_eq, np.zeros(self.nu))

substitute_parameters

systems.base.core.SymbolicSystemBase.substitute_parameters(expr)

Substitute numerical parameter values into symbolic expression.

Replaces all parameter symbols with their numerical values from self.parameters dictionary.

Parameters

Name Type Description Default
expr Union[sp.Expr, sp.Matrix] Symbolic expression or matrix required

Returns

Name Type Description
Union[sp.Expr, sp.Matrix] Expression with parameters substituted

Examples

>>> m, k = sp.symbols('m k')
>>> expr = m * sp.symbols('x') + k
>>> system.parameters = {m: 1.0, k: 10.0}
>>> system.substitute_parameters(expr)
x + 10.0

Notes

This is used internally by code generation to create parameter-specific numerical functions.

to_device

systems.base.core.SymbolicSystemBase.to_device(device)

Set preferred device for PyTorch/JAX backends.

Changes the device for all subsequent operations. Clears cached functions for backends that need recompilation (PyTorch, JAX) because device-specific code may differ.

Parameters

Name Type Description Default
device str Device string (‘cpu’, ‘cuda’, ‘cuda:0’, ‘gpu:0’, ‘tpu:0’, etc.) required

Returns

Name Type Description
SymbolicSystemBase Self (for method chaining)

Notes

  • NumPy always uses CPU (device setting ignored)
  • PyTorch and JAX respect device setting
  • Changing device clears cached functions for affected backends

Examples

>>> system.to_device('cuda:0')
>>> system.set_default_backend('torch')
>>> # All torch operations now use CUDA device 0

Method chaining:

>>> system.to_device('cuda').set_default_backend('torch')

use_backend

systems.base.core.SymbolicSystemBase.use_backend(backend, device=None)

Temporarily switch to a different backend and/or device.

This context manager allows temporary backend changes without affecting the configured default. Useful for benchmarking or comparing backend performance.

Parameters

Name Type Description Default
backend str Temporary backend to use (‘numpy’, ‘torch’, ‘jax’) required
device Optional[str] Temporary device to use (None = keep current device) None

Returns

Name Type Description
Generator[SymbolicSystemBase, None, None] Context manager yielding self with temporary backend configuration

Examples

>>> system.set_default_backend('numpy')
>>>
>>> # Temporarily use PyTorch
>>> with system.use_backend('torch', device='cuda'):
...     dx = system(x, u, backend='default')  # Uses torch on CUDA
>>>
>>> # Back to NumPy after context
>>> system._default_backend
'numpy'

Nested contexts:

>>> with system.use_backend('torch'):
...     with system.use_backend('jax'):
...         # Uses JAX
...         pass
...     # Back to torch
...     pass
>>> # Back to original