Source code for pathsim.utils.gilbert
########################################################################################
##
## METHODS FOR STATESPACE REALIZATIONS
## (utils/gilbert.py)
##
## Milan Rother 2024
##
########################################################################################
# IMPORTS ==============================================================================
import numpy as np
# STATESPACE REALIZATION ===============================================================
[docs]
def gilbert_realization(Poles=[], Residues=[], Const=0.0, tolerance=1e-9):
"""Build real valued statespace model from transfer function
in pole residue form by Gilbert's method and an additional
similarity transformation to get fully real valued matrices.
pole residue form:
.. math::
\\mathbf{H}(s) = \\mathmf{D} + \\sum_{n=1}^N \\frac{\\mathbf{R}_n}{s - p_n} )
statespace form:
.. math::
\\mathbf{H}(s) = \\mathbf{C} (s \\mathbf{I} - \\mathbf{A})^{-1} * \\mathbf{B} + \\mathbf{H}
Notes
-----
The resulting system is identical to the so-called
'Modal Form' and is a minimal realization.
Parameters
----------
Poles : array
real and complex poles
Residues : array
array of real and complex residue matrices
Const : array
matrix for constant term
tolerance : float
relative tolerance for checking real poles
Returns
-------
A : array
state matrix
B : array
input mapping matrix
C : array
state to output projection matrix
D : array, float
direct passthrough
Note
----
If some poles are complex-valued, their conjugate-values are automatically
added if missing, to enforce the model realness and stability.
"""
#make arrays
Poles = np.atleast_1d(Poles)
Residues = np.atleast_1d(Residues)
#check validity of args
if not len(Poles) or not len(Residues):
raise ValueError("No 'Poles' and 'Residues' defined!")
if len(Poles) != len(Residues):
raise ValueError("Same number of 'Poles' and 'Residues' have to be given!")
#go through poles and handle missing conjugate pairs if any
_Poles, _Residues = [], []
for p, R in zip(Poles, Residues):
# real pole
if np.isreal(p) or abs(np.imag(p) / np.real(p)) < tolerance:
_Poles.append(p.real)
_Residues.append(R.real)
# complex pole
else:
if not p in _Poles:
_Poles.append(p)
_Residues.append(R)
# add eventual missing conjugate pair
if not np.conj(p) in _Poles:
_Poles.append(np.conj(p))
_Residues.append(np.conj(R))
_Poles = np.asarray(_Poles)
_Residues = np.asarray(_Residues)
#check shape of residues for MIMO, etc
if _Residues.ndim == 1:
N, m, n = _Residues.size, 1, 1
_Residues = np.reshape(_Residues, (N, m, n))
elif _Residues.ndim == 2:
N, m, n = *_Residues.shape, 1
_Residues = np.reshape(_Residues, (N, m, n))
elif _Residues.ndim == 3:
N, m, n = _Residues.shape
else:
raise ValueError(f"shape mismatch of 'Residues': Residues.shape={_Residues.shape}")
#initialize companion matrix
a = np.zeros((N, N))
b = np.zeros(N)
#residues
C = np.ones((m, n*N))
#build real companion matrix from the poles
p_old = 0.0
for k, (p, R) in enumerate(zip(_Poles, _Residues)):
#check if complex conjugate
is_cc = (p.imag != 0.0 and p == np.conj(p_old))
p_old = p
a[k,k] = np.real(p)
b[k] = 1.0
if is_cc:
a[k, k-1] = - np.imag(p)
a[k-1, k] = np.imag(p)
b[k] = 0.0
b[k-1] = 2.0
#iterate columns of residue
for i in range(n):
C[:,k+N*i] = np.imag(R[:,i]) if is_cc else np.real(R[:,i])
#build block diagonal
A = np.kron(np.eye(n, dtype=float), a)
B = np.kron(np.eye(n, dtype=float), b).T
D = Const * np.ones((m, n)) if np.isscalar(Const) else Const
return A, B, C, D