_lti_conversion.py 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555
  1. """
  2. ltisys -- a collection of functions to convert linear time invariant systems
  3. from one representation to another.
  4. """
  5. import numpy as np
  6. from numpy import (r_, eye, atleast_2d, poly, dot,
  7. asarray, zeros, array, outer)
  8. from scipy import linalg
  9. from scipy._lib._array_api import array_namespace, xp_size
  10. import scipy._lib.array_api_extra as xpx
  11. from ._filter_design import tf2zpk, zpk2tf, normalize
  12. __all__ = ['tf2ss', 'abcd_normalize', 'ss2tf', 'zpk2ss', 'ss2zpk',
  13. 'cont2discrete']
  14. def tf2ss(num, den):
  15. r"""Transfer function to state-space representation.
  16. Parameters
  17. ----------
  18. num, den : array_like
  19. Sequences representing the coefficients of the numerator and
  20. denominator polynomials, in order of descending degree. The
  21. denominator needs to be at least as long as the numerator.
  22. Returns
  23. -------
  24. A, B, C, D : ndarray
  25. State space representation of the system, in controller canonical
  26. form.
  27. Examples
  28. --------
  29. Convert the transfer function:
  30. .. math:: H(s) = \frac{s^2 + 3s + 3}{s^2 + 2s + 1}
  31. >>> num = [1, 3, 3]
  32. >>> den = [1, 2, 1]
  33. to the state-space representation:
  34. .. math::
  35. \dot{\textbf{x}}(t) =
  36. \begin{bmatrix} -2 & -1 \\ 1 & 0 \end{bmatrix} \textbf{x}(t) +
  37. \begin{bmatrix} 1 \\ 0 \end{bmatrix} \textbf{u}(t) \\
  38. \textbf{y}(t) = \begin{bmatrix} 1 & 2 \end{bmatrix} \textbf{x}(t) +
  39. \begin{bmatrix} 1 \end{bmatrix} \textbf{u}(t)
  40. >>> from scipy.signal import tf2ss
  41. >>> A, B, C, D = tf2ss(num, den)
  42. >>> A
  43. array([[-2., -1.],
  44. [ 1., 0.]])
  45. >>> B
  46. array([[ 1.],
  47. [ 0.]])
  48. >>> C
  49. array([[ 1., 2.]])
  50. >>> D
  51. array([[ 1.]])
  52. """
  53. # Controller canonical state-space representation.
  54. # if M+1 = len(num) and K+1 = len(den) then we must have M <= K
  55. # states are found by asserting that X(s) = U(s) / D(s)
  56. # then Y(s) = N(s) * X(s)
  57. #
  58. # A, B, C, and D follow quite naturally.
  59. #
  60. num, den = normalize(num, den) # Strips zeros, checks arrays
  61. nn = len(num.shape)
  62. if nn == 1:
  63. num = asarray([num], num.dtype)
  64. M = num.shape[1]
  65. K = len(den)
  66. if M > K:
  67. msg = "Improper transfer function. `num` is longer than `den`."
  68. raise ValueError(msg)
  69. if M == 0 or K == 0: # Null system
  70. return (array([], float), array([], float), array([], float),
  71. array([], float))
  72. # pad numerator to have same number of columns has denominator
  73. num = np.hstack((np.zeros((num.shape[0], K - M), dtype=num.dtype), num))
  74. if num.shape[-1] > 0:
  75. D = atleast_2d(num[:, 0])
  76. else:
  77. # We don't assign it an empty array because this system
  78. # is not 'null'. It just doesn't have a non-zero D
  79. # matrix. Thus, it should have a non-zero shape so that
  80. # it can be operated on by functions like 'ss2tf'
  81. D = array([[0]], float)
  82. if K == 1:
  83. D = D.reshape(num.shape)
  84. return (zeros((1, 1)), zeros((1, D.shape[1])),
  85. zeros((D.shape[0], 1)), D)
  86. frow = -array([den[1:]])
  87. A = r_[frow, eye(K - 2, K - 1)]
  88. B = eye(K - 1, 1)
  89. C = num[:, 1:] - outer(num[:, 0], den[1:])
  90. D = D.reshape((C.shape[0], B.shape[1]))
  91. return A, B, C, D
  92. def abcd_normalize(A=None, B=None, C=None, D=None):
  93. """Check state-space matrices compatibility and ensure they are 2d arrays.
  94. Converts input matrices into two-dimensional arrays as needed. Then the dimensions
  95. n, q, p are determined by investigating the non-zero entries of the array shapes.
  96. If a parameter is ``None``, or has shape (0, 0), it is set to a
  97. zero-array of compatible shape. Finally, it is verified that all parameter shapes
  98. are compatible to each other. If that fails, a ``ValueError`` is raised. Note that
  99. the dimensions n, q, p are allowed to be zero.
  100. Parameters
  101. ----------
  102. A: array_like, optional
  103. Two-dimensional array of shape (n, n).
  104. B: array_like, optional
  105. Two-dimensional array of shape (n, p).
  106. C: array_like, optional
  107. Two-dimensional array of shape (q, n).
  108. D: array_like, optional
  109. Two-dimensional array of shape (q, p).
  110. Returns
  111. -------
  112. A, B, C, D : array
  113. State-space matrices as two-dimensional arrays.
  114. Notes
  115. -----
  116. The :ref:`tutorial_signal_state_space_representation` section of the
  117. :ref:`user_guide` presents the corresponding definitions of continuous-time and
  118. disrcete time state space systems.
  119. Raises
  120. ------
  121. ValueError
  122. If the dimensions n, q, or p could not be determined or if the shapes are
  123. incompatible with each other.
  124. See Also
  125. --------
  126. StateSpace: Linear Time Invariant system in state-space form.
  127. dlti: Discrete-time linear time invariant system base class.
  128. tf2ss: Transfer function to state-space representation.
  129. ss2tf: State-space to transfer function.
  130. ss2zpk: State-space representation to zero-pole-gain representation.
  131. cont2discrete: Transform a continuous to a discrete state-space system.
  132. Examples
  133. --------
  134. The following example demonstrates that the passed lists are converted into
  135. two-dimensional arrays:
  136. >>> from scipy.signal import abcd_normalize
  137. >>> AA, BB, CC, DD = abcd_normalize(A=[[1, 2], [3, 4]], B=[[-1], [5]],
  138. ... C=[[4, 5]], D=2.5)
  139. >>> AA.shape, BB.shape, CC.shape, DD.shape
  140. ((2, 2), (2, 1), (1, 2), (1, 1))
  141. In the following, the missing parameter C is assumed to be an array of zeros
  142. with shape (1, 2):
  143. >>> from scipy.signal import abcd_normalize
  144. >>> AA, BB, CC, DD = abcd_normalize(A=[[1, 2], [3, 4]], B=[[-1], [5]], D=2.5)
  145. >>> AA.shape, BB.shape, CC.shape, DD.shape
  146. ((2, 2), (2, 1), (1, 2), (1, 1))
  147. >>> CC
  148. array([[0., 0.]])
  149. """
  150. if A is None and B is None and C is None:
  151. raise ValueError("Dimension n is undefined for parameters A = B = C = None!")
  152. if B is None and D is None:
  153. raise ValueError("Dimension p is undefined for parameters B = D = None!")
  154. if C is None and D is None:
  155. raise ValueError("Dimension q is undefined for parameters C = D = None!")
  156. xp = array_namespace(A, B, C, D)
  157. A, B, C, D = (xpx.atleast_nd(xp.asarray(M_), ndim=2) if M_ is not None else
  158. xp.zeros((0, 0)) for M_ in (A, B, C, D))
  159. n = A.shape[0] or B.shape[0] or C.shape[1] or 0 # try finding non-zero dimensions
  160. p = B.shape[1] or D.shape[1] or 0
  161. q = C.shape[0] or D.shape[0] or 0
  162. A = xp.zeros((n, n)) if xp_size(A) == 0 else A # Create zero-matrices if needed
  163. B = xp.zeros((n, p)) if xp_size(B) == 0 else B
  164. C = xp.zeros((q, n)) if xp_size(C) == 0 else C
  165. D = xp.zeros((q, p)) if xp_size(D) == 0 else D
  166. if A.shape != (n, n):
  167. raise ValueError(f"Parameter A has shape {A.shape} but should be ({n}, {n})!")
  168. if B.shape != (n, p):
  169. raise ValueError(f"Parameter B has shape {B.shape} but should be ({n}, {p})!")
  170. if C.shape != (q, n):
  171. raise ValueError(f"Parameter C has shape {C.shape} but should be ({q}, {n})!")
  172. if D.shape != (q, p):
  173. raise ValueError(f"Parameter D has shape {D.shape} but should be ({q}, {p})!")
  174. return A, B, C, D
  175. def ss2tf(A, B, C, D, input=0):
  176. r"""State-space to transfer function.
  177. A, B, C, D defines a linear state-space system with `p` inputs,
  178. `q` outputs, and `n` state variables.
  179. Parameters
  180. ----------
  181. A : array_like
  182. State (or system) matrix of shape ``(n, n)``
  183. B : array_like
  184. Input matrix of shape ``(n, p)``
  185. C : array_like
  186. Output matrix of shape ``(q, n)``
  187. D : array_like
  188. Feedthrough (or feedforward) matrix of shape ``(q, p)``
  189. input : int, optional
  190. For multiple-input systems, the index of the input to use.
  191. Returns
  192. -------
  193. num : 2-D ndarray
  194. Numerator(s) of the resulting transfer function(s). `num` has one row
  195. for each of the system's outputs. Each row is a sequence representation
  196. of the numerator polynomial.
  197. den : 1-D ndarray
  198. Denominator of the resulting transfer function(s). `den` is a sequence
  199. representation of the denominator polynomial.
  200. Examples
  201. --------
  202. Convert the state-space representation:
  203. .. math::
  204. \dot{\textbf{x}}(t) =
  205. \begin{bmatrix} -2 & -1 \\ 1 & 0 \end{bmatrix} \textbf{x}(t) +
  206. \begin{bmatrix} 1 \\ 0 \end{bmatrix} \textbf{u}(t) \\
  207. \textbf{y}(t) = \begin{bmatrix} 1 & 2 \end{bmatrix} \textbf{x}(t) +
  208. \begin{bmatrix} 1 \end{bmatrix} \textbf{u}(t)
  209. >>> A = [[-2, -1], [1, 0]]
  210. >>> B = [[1], [0]] # 2-D column vector
  211. >>> C = [[1, 2]] # 2-D row vector
  212. >>> D = 1
  213. to the transfer function:
  214. .. math:: H(s) = \frac{s^2 + 3s + 3}{s^2 + 2s + 1}
  215. >>> from scipy.signal import ss2tf
  216. >>> ss2tf(A, B, C, D)
  217. (array([[1., 3., 3.]]), array([ 1., 2., 1.]))
  218. """
  219. # transfer function is C (sI - A)**(-1) B + D
  220. # Check consistency and make them all rank-2 arrays
  221. A, B, C, D = abcd_normalize(A, B, C, D)
  222. nout, nin = D.shape
  223. if input >= nin:
  224. raise ValueError("System does not have the input specified.")
  225. # make SIMO from possibly MIMO system.
  226. B = B[:, input:input + 1]
  227. D = D[:, input:input + 1]
  228. try:
  229. den = poly(A)
  230. except ValueError:
  231. den = 1
  232. if (B.size == 0) and (C.size == 0):
  233. num = np.ravel(D)
  234. if (D.size == 0) and (A.size == 0):
  235. den = []
  236. return num, den
  237. num_states = A.shape[0]
  238. type_test = A[:, 0] + B[:, 0] + C[0, :] + D + 0.0
  239. num = np.empty((nout, num_states + 1), type_test.dtype)
  240. for k in range(nout):
  241. Ck = atleast_2d(C[k, :])
  242. num[k] = poly(A - dot(B, Ck)) + (D[k] - 1) * den
  243. return num, den
  244. def zpk2ss(z, p, k):
  245. """Zero-pole-gain representation to state-space representation
  246. Parameters
  247. ----------
  248. z, p : sequence
  249. Zeros and poles.
  250. k : float
  251. System gain.
  252. Returns
  253. -------
  254. A, B, C, D : ndarray
  255. State space representation of the system, in controller canonical
  256. form.
  257. """
  258. return tf2ss(*zpk2tf(z, p, k))
  259. def ss2zpk(A, B, C, D, input=0):
  260. """State-space representation to zero-pole-gain representation.
  261. A, B, C, D defines a linear state-space system with `p` inputs,
  262. `q` outputs, and `n` state variables.
  263. Parameters
  264. ----------
  265. A : array_like
  266. State (or system) matrix of shape ``(n, n)``
  267. B : array_like
  268. Input matrix of shape ``(n, p)``
  269. C : array_like
  270. Output matrix of shape ``(q, n)``
  271. D : array_like
  272. Feedthrough (or feedforward) matrix of shape ``(q, p)``
  273. input : int, optional
  274. For multiple-input systems, the index of the input to use.
  275. Returns
  276. -------
  277. z, p : sequence
  278. Zeros and poles.
  279. k : float
  280. System gain.
  281. """
  282. return tf2zpk(*ss2tf(A, B, C, D, input=input))
  283. def cont2discrete(system, dt, method="zoh", alpha=None):
  284. """
  285. Transform a continuous to a discrete state-space system.
  286. Parameters
  287. ----------
  288. system : a tuple describing the system or an instance of `lti`
  289. The following gives the number of elements in the tuple and
  290. the interpretation:
  291. * 1: (instance of `lti`)
  292. * 2: (num, den)
  293. * 3: (zeros, poles, gain)
  294. * 4: (A, B, C, D)
  295. dt : float
  296. The discretization time step.
  297. method : str, optional
  298. Which method to use:
  299. * gbt: generalized bilinear transformation
  300. * bilinear: Tustin's approximation ("gbt" with alpha=0.5)
  301. * euler: Euler (or forward differencing) method ("gbt" with alpha=0)
  302. * backward_diff: Backwards differencing ("gbt" with alpha=1.0)
  303. * zoh: zero-order hold (default)
  304. * foh: first-order hold (*versionadded: 1.3.0*)
  305. * impulse: equivalent impulse response (*versionadded: 1.3.0*)
  306. alpha : float within [0, 1], optional
  307. The generalized bilinear transformation weighting parameter, which
  308. should only be specified with method="gbt", and is ignored otherwise
  309. Returns
  310. -------
  311. sysd : tuple containing the discrete system
  312. Based on the input type, the output will be of the form
  313. * (num, den, dt) for transfer function input
  314. * (zeros, poles, gain, dt) for zeros-poles-gain input
  315. * (A, B, C, D, dt) for state-space system input
  316. Notes
  317. -----
  318. By default, the routine uses a Zero-Order Hold (zoh) method to perform
  319. the transformation. Alternatively, a generalized bilinear transformation
  320. may be used, which includes the common Tustin's bilinear approximation,
  321. an Euler's method technique, or a backwards differencing technique.
  322. The Zero-Order Hold (zoh) method is based on [1]_, the generalized bilinear
  323. approximation is based on [2]_ and [3]_, the First-Order Hold (foh) method
  324. is based on [4]_.
  325. References
  326. ----------
  327. .. [1] https://en.wikipedia.org/wiki/Discretization#Discretization_of_linear_state_space_models
  328. .. [2] http://techteach.no/publications/discretetime_signals_systems/discrete.pdf
  329. .. [3] G. Zhang, X. Chen, and T. Chen, Digital redesign via the generalized
  330. bilinear transformation, Int. J. Control, vol. 82, no. 4, pp. 741-754,
  331. 2009.
  332. (https://www.mypolyuweb.hk/~magzhang/Research/ZCC09_IJC.pdf)
  333. .. [4] G. F. Franklin, J. D. Powell, and M. L. Workman, Digital control
  334. of dynamic systems, 3rd ed. Menlo Park, Calif: Addison-Wesley,
  335. pp. 204-206, 1998.
  336. Examples
  337. --------
  338. We can transform a continuous state-space system to a discrete one:
  339. >>> import numpy as np
  340. >>> import matplotlib.pyplot as plt
  341. >>> from scipy.signal import cont2discrete, lti, dlti, dstep
  342. Define a continuous state-space system.
  343. >>> A = np.array([[0, 1],[-10., -3]])
  344. >>> B = np.array([[0],[10.]])
  345. >>> C = np.array([[1., 0]])
  346. >>> D = np.array([[0.]])
  347. >>> l_system = lti(A, B, C, D)
  348. >>> t, x = l_system.step(T=np.linspace(0, 5, 100))
  349. >>> fig, ax = plt.subplots()
  350. >>> ax.plot(t, x, label='Continuous', linewidth=3)
  351. Transform it to a discrete state-space system using several methods.
  352. >>> dt = 0.1
  353. >>> for method in ['zoh', 'bilinear', 'euler', 'backward_diff', 'foh', 'impulse']:
  354. ... d_system = cont2discrete((A, B, C, D), dt, method=method)
  355. ... s, x_d = dstep(d_system)
  356. ... ax.step(s, np.squeeze(x_d), label=method, where='post')
  357. >>> ax.axis([t[0], t[-1], x[0], 1.4])
  358. >>> ax.legend(loc='best')
  359. >>> fig.tight_layout()
  360. >>> plt.show()
  361. """
  362. if hasattr(system, 'to_discrete') and callable(system.to_discrete):
  363. return system.to_discrete(dt=dt, method=method, alpha=alpha)
  364. if len(system) == 2:
  365. sysd = cont2discrete(tf2ss(system[0], system[1]), dt, method=method,
  366. alpha=alpha)
  367. return ss2tf(sysd[0], sysd[1], sysd[2], sysd[3]) + (dt,)
  368. elif len(system) == 3:
  369. sysd = cont2discrete(zpk2ss(system[0], system[1], system[2]), dt,
  370. method=method, alpha=alpha)
  371. return ss2zpk(sysd[0], sysd[1], sysd[2], sysd[3]) + (dt,)
  372. elif len(system) == 4:
  373. a, b, c, d = system
  374. else:
  375. raise ValueError("First argument must either be a tuple of 2 (tf), "
  376. "3 (zpk), or 4 (ss) arrays.")
  377. if method == 'gbt':
  378. if alpha is None:
  379. raise ValueError("Alpha parameter must be specified for the "
  380. "generalized bilinear transform (gbt) method")
  381. elif alpha < 0 or alpha > 1:
  382. raise ValueError("Alpha parameter must be within the interval "
  383. "[0,1] for the gbt method")
  384. if method == 'gbt':
  385. # This parameter is used repeatedly - compute once here
  386. ima = np.eye(a.shape[0]) - alpha*dt*a
  387. ad = linalg.solve(ima, np.eye(a.shape[0]) + (1.0-alpha)*dt*a)
  388. bd = linalg.solve(ima, dt*b)
  389. # Similarly solve for the output equation matrices
  390. cd = linalg.solve(ima.transpose(), c.transpose())
  391. cd = cd.transpose()
  392. dd = d + alpha*np.dot(c, bd)
  393. elif method == 'bilinear' or method == 'tustin':
  394. return cont2discrete(system, dt, method="gbt", alpha=0.5)
  395. elif method == 'euler' or method == 'forward_diff':
  396. return cont2discrete(system, dt, method="gbt", alpha=0.0)
  397. elif method == 'backward_diff':
  398. return cont2discrete(system, dt, method="gbt", alpha=1.0)
  399. elif method == 'zoh':
  400. # Build an exponential matrix
  401. em_upper = np.hstack((a, b))
  402. # Need to stack zeros under the a and b matrices
  403. em_lower = np.hstack((np.zeros((b.shape[1], a.shape[0])),
  404. np.zeros((b.shape[1], b.shape[1]))))
  405. em = np.vstack((em_upper, em_lower))
  406. ms = linalg.expm(dt * em)
  407. # Dispose of the lower rows
  408. ms = ms[:a.shape[0], :]
  409. ad = ms[:, 0:a.shape[1]]
  410. bd = ms[:, a.shape[1]:]
  411. cd = c
  412. dd = d
  413. elif method == 'foh':
  414. # Size parameters for convenience
  415. n = a.shape[0]
  416. m = b.shape[1]
  417. # Build an exponential matrix similar to 'zoh' method
  418. em_upper = linalg.block_diag(np.block([a, b]) * dt, np.eye(m))
  419. em_lower = zeros((m, n + 2 * m))
  420. em = np.block([[em_upper], [em_lower]])
  421. ms = linalg.expm(em)
  422. # Get the three blocks from upper rows
  423. ms11 = ms[:n, 0:n]
  424. ms12 = ms[:n, n:n + m]
  425. ms13 = ms[:n, n + m:]
  426. ad = ms11
  427. bd = ms12 - ms13 + ms11 @ ms13
  428. cd = c
  429. dd = d + c @ ms13
  430. elif method == 'impulse':
  431. if not np.allclose(d, 0):
  432. raise ValueError("Impulse method is only applicable "
  433. "to strictly proper systems")
  434. ad = linalg.expm(a * dt)
  435. bd = ad @ b * dt
  436. cd = c
  437. dd = c @ b * dt
  438. else:
  439. raise ValueError(f"Unknown transformation method '{method}'")
  440. return ad, bd, cd, dd, dt