534 lines
16 KiB
Python
534 lines
16 KiB
Python
|
"""
|
||
|
ltisys -- a collection of functions to convert linear time invariant systems
|
||
|
from one representation to another.
|
||
|
"""
|
||
|
import numpy
|
||
|
import numpy as np
|
||
|
from numpy import (r_, eye, atleast_2d, poly, dot,
|
||
|
asarray, prod, zeros, array, outer)
|
||
|
from scipy import linalg
|
||
|
|
||
|
from ._filter_design import tf2zpk, zpk2tf, normalize
|
||
|
|
||
|
|
||
|
__all__ = ['tf2ss', 'abcd_normalize', 'ss2tf', 'zpk2ss', 'ss2zpk',
|
||
|
'cont2discrete']
|
||
|
|
||
|
|
||
|
def tf2ss(num, den):
|
||
|
r"""Transfer function to state-space representation.
|
||
|
|
||
|
Parameters
|
||
|
----------
|
||
|
num, den : array_like
|
||
|
Sequences representing the coefficients of the numerator and
|
||
|
denominator polynomials, in order of descending degree. The
|
||
|
denominator needs to be at least as long as the numerator.
|
||
|
|
||
|
Returns
|
||
|
-------
|
||
|
A, B, C, D : ndarray
|
||
|
State space representation of the system, in controller canonical
|
||
|
form.
|
||
|
|
||
|
Examples
|
||
|
--------
|
||
|
Convert the transfer function:
|
||
|
|
||
|
.. math:: H(s) = \frac{s^2 + 3s + 3}{s^2 + 2s + 1}
|
||
|
|
||
|
>>> num = [1, 3, 3]
|
||
|
>>> den = [1, 2, 1]
|
||
|
|
||
|
to the state-space representation:
|
||
|
|
||
|
.. math::
|
||
|
|
||
|
\dot{\textbf{x}}(t) =
|
||
|
\begin{bmatrix} -2 & -1 \\ 1 & 0 \end{bmatrix} \textbf{x}(t) +
|
||
|
\begin{bmatrix} 1 \\ 0 \end{bmatrix} \textbf{u}(t) \\
|
||
|
|
||
|
\textbf{y}(t) = \begin{bmatrix} 1 & 2 \end{bmatrix} \textbf{x}(t) +
|
||
|
\begin{bmatrix} 1 \end{bmatrix} \textbf{u}(t)
|
||
|
|
||
|
>>> from scipy.signal import tf2ss
|
||
|
>>> A, B, C, D = tf2ss(num, den)
|
||
|
>>> A
|
||
|
array([[-2., -1.],
|
||
|
[ 1., 0.]])
|
||
|
>>> B
|
||
|
array([[ 1.],
|
||
|
[ 0.]])
|
||
|
>>> C
|
||
|
array([[ 1., 2.]])
|
||
|
>>> D
|
||
|
array([[ 1.]])
|
||
|
"""
|
||
|
# Controller canonical state-space representation.
|
||
|
# if M+1 = len(num) and K+1 = len(den) then we must have M <= K
|
||
|
# states are found by asserting that X(s) = U(s) / D(s)
|
||
|
# then Y(s) = N(s) * X(s)
|
||
|
#
|
||
|
# A, B, C, and D follow quite naturally.
|
||
|
#
|
||
|
num, den = normalize(num, den) # Strips zeros, checks arrays
|
||
|
nn = len(num.shape)
|
||
|
if nn == 1:
|
||
|
num = asarray([num], num.dtype)
|
||
|
M = num.shape[1]
|
||
|
K = len(den)
|
||
|
if M > K:
|
||
|
msg = "Improper transfer function. `num` is longer than `den`."
|
||
|
raise ValueError(msg)
|
||
|
if M == 0 or K == 0: # Null system
|
||
|
return (array([], float), array([], float), array([], float),
|
||
|
array([], float))
|
||
|
|
||
|
# pad numerator to have same number of columns has denominator
|
||
|
num = r_['-1', zeros((num.shape[0], K - M), num.dtype), num]
|
||
|
|
||
|
if num.shape[-1] > 0:
|
||
|
D = atleast_2d(num[:, 0])
|
||
|
|
||
|
else:
|
||
|
# We don't assign it an empty array because this system
|
||
|
# is not 'null'. It just doesn't have a non-zero D
|
||
|
# matrix. Thus, it should have a non-zero shape so that
|
||
|
# it can be operated on by functions like 'ss2tf'
|
||
|
D = array([[0]], float)
|
||
|
|
||
|
if K == 1:
|
||
|
D = D.reshape(num.shape)
|
||
|
|
||
|
return (zeros((1, 1)), zeros((1, D.shape[1])),
|
||
|
zeros((D.shape[0], 1)), D)
|
||
|
|
||
|
frow = -array([den[1:]])
|
||
|
A = r_[frow, eye(K - 2, K - 1)]
|
||
|
B = eye(K - 1, 1)
|
||
|
C = num[:, 1:] - outer(num[:, 0], den[1:])
|
||
|
D = D.reshape((C.shape[0], B.shape[1]))
|
||
|
|
||
|
return A, B, C, D
|
||
|
|
||
|
|
||
|
def _none_to_empty_2d(arg):
|
||
|
if arg is None:
|
||
|
return zeros((0, 0))
|
||
|
else:
|
||
|
return arg
|
||
|
|
||
|
|
||
|
def _atleast_2d_or_none(arg):
|
||
|
if arg is not None:
|
||
|
return atleast_2d(arg)
|
||
|
|
||
|
|
||
|
def _shape_or_none(M):
|
||
|
if M is not None:
|
||
|
return M.shape
|
||
|
else:
|
||
|
return (None,) * 2
|
||
|
|
||
|
|
||
|
def _choice_not_none(*args):
|
||
|
for arg in args:
|
||
|
if arg is not None:
|
||
|
return arg
|
||
|
|
||
|
|
||
|
def _restore(M, shape):
|
||
|
if M.shape == (0, 0):
|
||
|
return zeros(shape)
|
||
|
else:
|
||
|
if M.shape != shape:
|
||
|
raise ValueError("The input arrays have incompatible shapes.")
|
||
|
return M
|
||
|
|
||
|
|
||
|
def abcd_normalize(A=None, B=None, C=None, D=None):
|
||
|
"""Check state-space matrices and ensure they are 2-D.
|
||
|
|
||
|
If enough information on the system is provided, that is, enough
|
||
|
properly-shaped arrays are passed to the function, the missing ones
|
||
|
are built from this information, ensuring the correct number of
|
||
|
rows and columns. Otherwise a ValueError is raised.
|
||
|
|
||
|
Parameters
|
||
|
----------
|
||
|
A, B, C, D : array_like, optional
|
||
|
State-space matrices. All of them are None (missing) by default.
|
||
|
See `ss2tf` for format.
|
||
|
|
||
|
Returns
|
||
|
-------
|
||
|
A, B, C, D : array
|
||
|
Properly shaped state-space matrices.
|
||
|
|
||
|
Raises
|
||
|
------
|
||
|
ValueError
|
||
|
If not enough information on the system was provided.
|
||
|
|
||
|
"""
|
||
|
A, B, C, D = map(_atleast_2d_or_none, (A, B, C, D))
|
||
|
|
||
|
MA, NA = _shape_or_none(A)
|
||
|
MB, NB = _shape_or_none(B)
|
||
|
MC, NC = _shape_or_none(C)
|
||
|
MD, ND = _shape_or_none(D)
|
||
|
|
||
|
p = _choice_not_none(MA, MB, NC)
|
||
|
q = _choice_not_none(NB, ND)
|
||
|
r = _choice_not_none(MC, MD)
|
||
|
if p is None or q is None or r is None:
|
||
|
raise ValueError("Not enough information on the system.")
|
||
|
|
||
|
A, B, C, D = map(_none_to_empty_2d, (A, B, C, D))
|
||
|
A = _restore(A, (p, p))
|
||
|
B = _restore(B, (p, q))
|
||
|
C = _restore(C, (r, p))
|
||
|
D = _restore(D, (r, q))
|
||
|
|
||
|
return A, B, C, D
|
||
|
|
||
|
|
||
|
def ss2tf(A, B, C, D, input=0):
|
||
|
r"""State-space to transfer function.
|
||
|
|
||
|
A, B, C, D defines a linear state-space system with `p` inputs,
|
||
|
`q` outputs, and `n` state variables.
|
||
|
|
||
|
Parameters
|
||
|
----------
|
||
|
A : array_like
|
||
|
State (or system) matrix of shape ``(n, n)``
|
||
|
B : array_like
|
||
|
Input matrix of shape ``(n, p)``
|
||
|
C : array_like
|
||
|
Output matrix of shape ``(q, n)``
|
||
|
D : array_like
|
||
|
Feedthrough (or feedforward) matrix of shape ``(q, p)``
|
||
|
input : int, optional
|
||
|
For multiple-input systems, the index of the input to use.
|
||
|
|
||
|
Returns
|
||
|
-------
|
||
|
num : 2-D ndarray
|
||
|
Numerator(s) of the resulting transfer function(s). `num` has one row
|
||
|
for each of the system's outputs. Each row is a sequence representation
|
||
|
of the numerator polynomial.
|
||
|
den : 1-D ndarray
|
||
|
Denominator of the resulting transfer function(s). `den` is a sequence
|
||
|
representation of the denominator polynomial.
|
||
|
|
||
|
Examples
|
||
|
--------
|
||
|
Convert the state-space representation:
|
||
|
|
||
|
.. math::
|
||
|
|
||
|
\dot{\textbf{x}}(t) =
|
||
|
\begin{bmatrix} -2 & -1 \\ 1 & 0 \end{bmatrix} \textbf{x}(t) +
|
||
|
\begin{bmatrix} 1 \\ 0 \end{bmatrix} \textbf{u}(t) \\
|
||
|
|
||
|
\textbf{y}(t) = \begin{bmatrix} 1 & 2 \end{bmatrix} \textbf{x}(t) +
|
||
|
\begin{bmatrix} 1 \end{bmatrix} \textbf{u}(t)
|
||
|
|
||
|
>>> A = [[-2, -1], [1, 0]]
|
||
|
>>> B = [[1], [0]] # 2-D column vector
|
||
|
>>> C = [[1, 2]] # 2-D row vector
|
||
|
>>> D = 1
|
||
|
|
||
|
to the transfer function:
|
||
|
|
||
|
.. math:: H(s) = \frac{s^2 + 3s + 3}{s^2 + 2s + 1}
|
||
|
|
||
|
>>> from scipy.signal import ss2tf
|
||
|
>>> ss2tf(A, B, C, D)
|
||
|
(array([[1., 3., 3.]]), array([ 1., 2., 1.]))
|
||
|
"""
|
||
|
# transfer function is C (sI - A)**(-1) B + D
|
||
|
|
||
|
# Check consistency and make them all rank-2 arrays
|
||
|
A, B, C, D = abcd_normalize(A, B, C, D)
|
||
|
|
||
|
nout, nin = D.shape
|
||
|
if input >= nin:
|
||
|
raise ValueError("System does not have the input specified.")
|
||
|
|
||
|
# make SIMO from possibly MIMO system.
|
||
|
B = B[:, input:input + 1]
|
||
|
D = D[:, input:input + 1]
|
||
|
|
||
|
try:
|
||
|
den = poly(A)
|
||
|
except ValueError:
|
||
|
den = 1
|
||
|
|
||
|
if (prod(B.shape, axis=0) == 0) and (prod(C.shape, axis=0) == 0):
|
||
|
num = numpy.ravel(D)
|
||
|
if (prod(D.shape, axis=0) == 0) and (prod(A.shape, axis=0) == 0):
|
||
|
den = []
|
||
|
return num, den
|
||
|
|
||
|
num_states = A.shape[0]
|
||
|
type_test = A[:, 0] + B[:, 0] + C[0, :] + D + 0.0
|
||
|
num = numpy.empty((nout, num_states + 1), type_test.dtype)
|
||
|
for k in range(nout):
|
||
|
Ck = atleast_2d(C[k, :])
|
||
|
num[k] = poly(A - dot(B, Ck)) + (D[k] - 1) * den
|
||
|
|
||
|
return num, den
|
||
|
|
||
|
|
||
|
def zpk2ss(z, p, k):
|
||
|
"""Zero-pole-gain representation to state-space representation
|
||
|
|
||
|
Parameters
|
||
|
----------
|
||
|
z, p : sequence
|
||
|
Zeros and poles.
|
||
|
k : float
|
||
|
System gain.
|
||
|
|
||
|
Returns
|
||
|
-------
|
||
|
A, B, C, D : ndarray
|
||
|
State space representation of the system, in controller canonical
|
||
|
form.
|
||
|
|
||
|
"""
|
||
|
return tf2ss(*zpk2tf(z, p, k))
|
||
|
|
||
|
|
||
|
def ss2zpk(A, B, C, D, input=0):
|
||
|
"""State-space representation to zero-pole-gain representation.
|
||
|
|
||
|
A, B, C, D defines a linear state-space system with `p` inputs,
|
||
|
`q` outputs, and `n` state variables.
|
||
|
|
||
|
Parameters
|
||
|
----------
|
||
|
A : array_like
|
||
|
State (or system) matrix of shape ``(n, n)``
|
||
|
B : array_like
|
||
|
Input matrix of shape ``(n, p)``
|
||
|
C : array_like
|
||
|
Output matrix of shape ``(q, n)``
|
||
|
D : array_like
|
||
|
Feedthrough (or feedforward) matrix of shape ``(q, p)``
|
||
|
input : int, optional
|
||
|
For multiple-input systems, the index of the input to use.
|
||
|
|
||
|
Returns
|
||
|
-------
|
||
|
z, p : sequence
|
||
|
Zeros and poles.
|
||
|
k : float
|
||
|
System gain.
|
||
|
|
||
|
"""
|
||
|
return tf2zpk(*ss2tf(A, B, C, D, input=input))
|
||
|
|
||
|
|
||
|
def cont2discrete(system, dt, method="zoh", alpha=None):
|
||
|
"""
|
||
|
Transform a continuous to a discrete state-space system.
|
||
|
|
||
|
Parameters
|
||
|
----------
|
||
|
system : a tuple describing the system or an instance of `lti`
|
||
|
The following gives the number of elements in the tuple and
|
||
|
the interpretation:
|
||
|
|
||
|
* 1: (instance of `lti`)
|
||
|
* 2: (num, den)
|
||
|
* 3: (zeros, poles, gain)
|
||
|
* 4: (A, B, C, D)
|
||
|
|
||
|
dt : float
|
||
|
The discretization time step.
|
||
|
method : str, optional
|
||
|
Which method to use:
|
||
|
|
||
|
* gbt: generalized bilinear transformation
|
||
|
* bilinear: Tustin's approximation ("gbt" with alpha=0.5)
|
||
|
* euler: Euler (or forward differencing) method ("gbt" with alpha=0)
|
||
|
* backward_diff: Backwards differencing ("gbt" with alpha=1.0)
|
||
|
* zoh: zero-order hold (default)
|
||
|
* foh: first-order hold (*versionadded: 1.3.0*)
|
||
|
* impulse: equivalent impulse response (*versionadded: 1.3.0*)
|
||
|
|
||
|
alpha : float within [0, 1], optional
|
||
|
The generalized bilinear transformation weighting parameter, which
|
||
|
should only be specified with method="gbt", and is ignored otherwise
|
||
|
|
||
|
Returns
|
||
|
-------
|
||
|
sysd : tuple containing the discrete system
|
||
|
Based on the input type, the output will be of the form
|
||
|
|
||
|
* (num, den, dt) for transfer function input
|
||
|
* (zeros, poles, gain, dt) for zeros-poles-gain input
|
||
|
* (A, B, C, D, dt) for state-space system input
|
||
|
|
||
|
Notes
|
||
|
-----
|
||
|
By default, the routine uses a Zero-Order Hold (zoh) method to perform
|
||
|
the transformation. Alternatively, a generalized bilinear transformation
|
||
|
may be used, which includes the common Tustin's bilinear approximation,
|
||
|
an Euler's method technique, or a backwards differencing technique.
|
||
|
|
||
|
The Zero-Order Hold (zoh) method is based on [1]_, the generalized bilinear
|
||
|
approximation is based on [2]_ and [3]_, the First-Order Hold (foh) method
|
||
|
is based on [4]_.
|
||
|
|
||
|
References
|
||
|
----------
|
||
|
.. [1] https://en.wikipedia.org/wiki/Discretization#Discretization_of_linear_state_space_models
|
||
|
|
||
|
.. [2] http://techteach.no/publications/discretetime_signals_systems/discrete.pdf
|
||
|
|
||
|
.. [3] G. Zhang, X. Chen, and T. Chen, Digital redesign via the generalized
|
||
|
bilinear transformation, Int. J. Control, vol. 82, no. 4, pp. 741-754,
|
||
|
2009.
|
||
|
(https://www.mypolyuweb.hk/~magzhang/Research/ZCC09_IJC.pdf)
|
||
|
|
||
|
.. [4] G. F. Franklin, J. D. Powell, and M. L. Workman, Digital control
|
||
|
of dynamic systems, 3rd ed. Menlo Park, Calif: Addison-Wesley,
|
||
|
pp. 204-206, 1998.
|
||
|
|
||
|
Examples
|
||
|
--------
|
||
|
We can transform a continuous state-space system to a discrete one:
|
||
|
|
||
|
>>> import numpy as np
|
||
|
>>> import matplotlib.pyplot as plt
|
||
|
>>> from scipy.signal import cont2discrete, lti, dlti, dstep
|
||
|
|
||
|
Define a continuous state-space system.
|
||
|
|
||
|
>>> A = np.array([[0, 1],[-10., -3]])
|
||
|
>>> B = np.array([[0],[10.]])
|
||
|
>>> C = np.array([[1., 0]])
|
||
|
>>> D = np.array([[0.]])
|
||
|
>>> l_system = lti(A, B, C, D)
|
||
|
>>> t, x = l_system.step(T=np.linspace(0, 5, 100))
|
||
|
>>> fig, ax = plt.subplots()
|
||
|
>>> ax.plot(t, x, label='Continuous', linewidth=3)
|
||
|
|
||
|
Transform it to a discrete state-space system using several methods.
|
||
|
|
||
|
>>> dt = 0.1
|
||
|
>>> for method in ['zoh', 'bilinear', 'euler', 'backward_diff', 'foh', 'impulse']:
|
||
|
... d_system = cont2discrete((A, B, C, D), dt, method=method)
|
||
|
... s, x_d = dstep(d_system)
|
||
|
... ax.step(s, np.squeeze(x_d), label=method, where='post')
|
||
|
>>> ax.axis([t[0], t[-1], x[0], 1.4])
|
||
|
>>> ax.legend(loc='best')
|
||
|
>>> fig.tight_layout()
|
||
|
>>> plt.show()
|
||
|
|
||
|
"""
|
||
|
if len(system) == 1:
|
||
|
return system.to_discrete()
|
||
|
if len(system) == 2:
|
||
|
sysd = cont2discrete(tf2ss(system[0], system[1]), dt, method=method,
|
||
|
alpha=alpha)
|
||
|
return ss2tf(sysd[0], sysd[1], sysd[2], sysd[3]) + (dt,)
|
||
|
elif len(system) == 3:
|
||
|
sysd = cont2discrete(zpk2ss(system[0], system[1], system[2]), dt,
|
||
|
method=method, alpha=alpha)
|
||
|
return ss2zpk(sysd[0], sysd[1], sysd[2], sysd[3]) + (dt,)
|
||
|
elif len(system) == 4:
|
||
|
a, b, c, d = system
|
||
|
else:
|
||
|
raise ValueError("First argument must either be a tuple of 2 (tf), "
|
||
|
"3 (zpk), or 4 (ss) arrays.")
|
||
|
|
||
|
if method == 'gbt':
|
||
|
if alpha is None:
|
||
|
raise ValueError("Alpha parameter must be specified for the "
|
||
|
"generalized bilinear transform (gbt) method")
|
||
|
elif alpha < 0 or alpha > 1:
|
||
|
raise ValueError("Alpha parameter must be within the interval "
|
||
|
"[0,1] for the gbt method")
|
||
|
|
||
|
if method == 'gbt':
|
||
|
# This parameter is used repeatedly - compute once here
|
||
|
ima = np.eye(a.shape[0]) - alpha*dt*a
|
||
|
ad = linalg.solve(ima, np.eye(a.shape[0]) + (1.0-alpha)*dt*a)
|
||
|
bd = linalg.solve(ima, dt*b)
|
||
|
|
||
|
# Similarly solve for the output equation matrices
|
||
|
cd = linalg.solve(ima.transpose(), c.transpose())
|
||
|
cd = cd.transpose()
|
||
|
dd = d + alpha*np.dot(c, bd)
|
||
|
|
||
|
elif method == 'bilinear' or method == 'tustin':
|
||
|
return cont2discrete(system, dt, method="gbt", alpha=0.5)
|
||
|
|
||
|
elif method == 'euler' or method == 'forward_diff':
|
||
|
return cont2discrete(system, dt, method="gbt", alpha=0.0)
|
||
|
|
||
|
elif method == 'backward_diff':
|
||
|
return cont2discrete(system, dt, method="gbt", alpha=1.0)
|
||
|
|
||
|
elif method == 'zoh':
|
||
|
# Build an exponential matrix
|
||
|
em_upper = np.hstack((a, b))
|
||
|
|
||
|
# Need to stack zeros under the a and b matrices
|
||
|
em_lower = np.hstack((np.zeros((b.shape[1], a.shape[0])),
|
||
|
np.zeros((b.shape[1], b.shape[1]))))
|
||
|
|
||
|
em = np.vstack((em_upper, em_lower))
|
||
|
ms = linalg.expm(dt * em)
|
||
|
|
||
|
# Dispose of the lower rows
|
||
|
ms = ms[:a.shape[0], :]
|
||
|
|
||
|
ad = ms[:, 0:a.shape[1]]
|
||
|
bd = ms[:, a.shape[1]:]
|
||
|
|
||
|
cd = c
|
||
|
dd = d
|
||
|
|
||
|
elif method == 'foh':
|
||
|
# Size parameters for convenience
|
||
|
n = a.shape[0]
|
||
|
m = b.shape[1]
|
||
|
|
||
|
# Build an exponential matrix similar to 'zoh' method
|
||
|
em_upper = linalg.block_diag(np.block([a, b]) * dt, np.eye(m))
|
||
|
em_lower = zeros((m, n + 2 * m))
|
||
|
em = np.block([[em_upper], [em_lower]])
|
||
|
|
||
|
ms = linalg.expm(em)
|
||
|
|
||
|
# Get the three blocks from upper rows
|
||
|
ms11 = ms[:n, 0:n]
|
||
|
ms12 = ms[:n, n:n + m]
|
||
|
ms13 = ms[:n, n + m:]
|
||
|
|
||
|
ad = ms11
|
||
|
bd = ms12 - ms13 + ms11 @ ms13
|
||
|
cd = c
|
||
|
dd = d + c @ ms13
|
||
|
|
||
|
elif method == 'impulse':
|
||
|
if not np.allclose(d, 0):
|
||
|
raise ValueError("Impulse method is only applicable"
|
||
|
"to strictly proper systems")
|
||
|
|
||
|
ad = linalg.expm(a * dt)
|
||
|
bd = ad @ b * dt
|
||
|
cd = c
|
||
|
dd = c @ b * dt
|
||
|
|
||
|
else:
|
||
|
raise ValueError("Unknown transformation method '%s'" % method)
|
||
|
|
||
|
return ad, bd, cd, dd, dt
|