Source code for pySimBlocks.blocks.systems.linear_state_space

# ******************************************************************************
#                                  pySimBlocks
#                     Copyright (c) 2026 Université de Lille & INRIA
# ******************************************************************************
#  This program is free software: you can redistribute it and/or modify it
#  under the terms of the GNU Lesser General Public License as published by
#  the Free Software Foundation, either version 3 of the License, or (at your
#  option) any later version.
#
#  This program is distributed in the hope that it will be useful, but WITHOUT
#  ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
#  FITNESS FOR A PARTICULAR PURPOSE.  See the GNU Lesser General Public License
#  for more details.
#
#  You should have received a copy of the GNU Lesser General Public License
#  along with this program.  If not, see <https://www.gnu.org/licenses/>.
# ******************************************************************************
#  Authors: see Authors.txt
# ******************************************************************************

from __future__ import annotations

import numpy as np
from numpy.typing import ArrayLike

from pySimBlocks.core.block import Block


[docs] class LinearStateSpace(Block): """Discrete-time linear state-space system block. Implements a strictly proper discrete-time linear system: x[k+1] = A x[k] + B u[k] y[k] = C x[k] The D matrix is intentionally not supported to avoid algebraic loops. Attributes: A: State transition matrix of shape (n, n). B: Input matrix of shape (n, m). C: Output matrix of shape (p, n). """ direct_feedthrough = False def __init__( self, name: str, A: ArrayLike, B: ArrayLike, C: ArrayLike, x0: ArrayLike | None = None, sample_time: float | None = None, ): """Initialize a LinearStateSpace block. Args: name: Unique identifier for this block instance. A: State transition matrix, array-like of shape (n, n). B: Input matrix, array-like of shape (n, m). C: Output matrix, array-like of shape (p, n). x0: Initial state vector, array-like of shape (n, 1) or (n,). Defaults to zeros. sample_time: Sampling period in seconds, or None to use the global simulation dt. Raises: ValueError: If any matrix is not 2D, if dimensions are inconsistent, or if x0 does not match the state dimension. """ super().__init__(name, sample_time) self.A = np.asarray(A, dtype=float) self.B = np.asarray(B, dtype=float) self.C = np.asarray(C, dtype=float) if self.A.ndim != 2: raise ValueError(f"[{self.name}] A must be 2D. Got shape {self.A.shape}.") if self.B.ndim != 2: raise ValueError(f"[{self.name}] B must be 2D. Got shape {self.B.shape}.") if self.C.ndim != 2: raise ValueError(f"[{self.name}] C must be 2D. Got shape {self.C.shape}.") n = self.A.shape[0] if self.A.shape != (n, n): raise ValueError(f"[{self.name}] A must be square (n,n). Got {self.A.shape}.") if self.B.shape[0] != n: raise ValueError( f"[{self.name}] B must have n rows. A is {self.A.shape}, B is {self.B.shape}." ) if self.C.shape[1] != n: raise ValueError( f"[{self.name}] C must have n columns. A is {self.A.shape}, C is {self.C.shape}." ) self._n = n self._m = self.B.shape[1] self._p = self.C.shape[0] if x0 is None: x0_arr = np.zeros((n, 1), dtype=float) else: x0_arr = np.asarray(x0, dtype=float) if x0_arr.ndim == 0: x0_arr = x0_arr.reshape(1, 1) elif x0_arr.ndim == 1: x0_arr = x0_arr.reshape(-1, 1) elif x0_arr.ndim == 2: pass else: raise ValueError(f"[{self.name}] x0 must be 1D or 2D. Got shape {x0_arr.shape}.") if x0_arr.shape != (n, 1): raise ValueError(f"[{self.name}] x0 must have shape ({n}, 1). Got {x0_arr.shape}.") self.state["x"] = x0_arr.copy() self.next_state["x"] = x0_arr.copy() self.inputs["u"] = None self.outputs["y"] = None self.outputs["x"] = None # -------------------------------------------------------------------------- # Public methods # --------------------------------------------------------------------------
[docs] def initialize(self, t0: float) -> None: """Compute initial outputs from the initial state. Args: t0: Initial simulation time in seconds. """ x = self.state["x"] self.outputs["y"] = self.C @ x self.outputs["x"] = x.copy() self.next_state["x"] = x.copy()
[docs] def output_update(self, t: float, dt: float) -> None: """Compute y and x outputs from the committed state. Args: t: Current simulation time in seconds. dt: Current time step in seconds. """ x = self.state["x"] self.outputs["y"] = self.C @ x self.outputs["x"] = x.copy()
[docs] def state_update(self, t: float, dt: float) -> None: """Compute the next state x[k+1] = A x[k] + B u[k]. Args: t: Current simulation time in seconds. dt: Current time step in seconds. Raises: RuntimeError: If input ``u`` is not connected. ValueError: If input ``u`` has the wrong shape. """ u = self.inputs["u"] if u is None: raise RuntimeError(f"[{self.name}] Input 'u' is not connected or not set.") u_vec = self._to_col_vec("u", u, self._m) x = self.state["x"] self.next_state["x"] = self.A @ x + self.B @ u_vec
# -------------------------------------------------------------------------- # Private methods # -------------------------------------------------------------------------- def _to_col_vec(self, name: str, value: ArrayLike, expected_rows: int) -> np.ndarray: """Normalize value to a (n,1) column vector and validate its size.""" arr = np.asarray(value, dtype=float) if arr.ndim == 0: arr = arr.reshape(1, 1) elif arr.ndim == 1: arr = arr.reshape(-1, 1) elif arr.ndim == 2: pass else: raise ValueError(f"[{self.name}] {name} must be 1D or 2D. Got shape {arr.shape}.") if arr.shape[1] != 1: raise ValueError(f"[{self.name}] {name} must be a column vector (k,1). Got {arr.shape}.") if arr.shape[0] != expected_rows: raise ValueError( f"[{self.name}] {name} must have shape ({expected_rows},1). Got {arr.shape}." ) return arr