LSR/env/lib/python3.6/site-packages/control/xferfcn.py

1507 lines
54 KiB
Python
Raw Normal View History

2020-06-04 17:24:47 +02:00
"""xferfcn.py
Transfer function representation and functions.
This file contains the TransferFunction class and also functions
that operate on transfer functions. This is the primary representation
for the python-control library.
"""
# Python 3 compatibility (needs to go here)
from __future__ import print_function
from __future__ import division
"""Copyright (c) 2010 by California Institute of Technology
All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions
are met:
1. Redistributions of source code must retain the above copyright
notice, this list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright
notice, this list of conditions and the following disclaimer in the
documentation and/or other materials provided with the distribution.
3. Neither the name of the California Institute of Technology nor
the names of its contributors may be used to endorse or promote
products derived from this software without specific prior
written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS
FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL CALTECH
OR THE CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF
USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT
OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF
SUCH DAMAGE.
Author: Richard M. Murray
Date: 24 May 09
Revised: Kevin K. Chen, Dec 10
$Id$
"""
# External function declarations
import numpy as np
from numpy import angle, array, empty, finfo, ndarray, ones, \
polyadd, polymul, polyval, roots, sqrt, zeros, squeeze, exp, pi, \
where, delete, real, poly, nonzero
import scipy as sp
from scipy.signal import lti, tf2zpk, zpk2tf, cont2discrete
from copy import deepcopy
from warnings import warn
from itertools import chain
from re import sub
from .lti import LTI, timebaseEqual, timebase, isdtime
__all__ = ['TransferFunction', 'tf', 'ss2tf', 'tfdata']
class TransferFunction(LTI):
"""TransferFunction(num, den[, dt])
A class for representing transfer functions
The TransferFunction class is used to represent systems in transfer
function form.
The main data members are 'num' and 'den', which are 2-D lists of arrays
containing MIMO numerator and denominator coefficients. For example,
>>> num[2][5] = numpy.array([1., 4., 8.])
means that the numerator of the transfer function from the 6th input to the
3rd output is set to s^2 + 4s + 8.
Discrete-time transfer functions are implemented by using the 'dt'
instance variable and setting it to something other than 'None'. If 'dt'
has a non-zero value, then it must match whenever two transfer functions
are combined. If 'dt' is set to True, the system will be treated as a
discrete time system with unspecified sampling time.
The TransferFunction class defines two constants ``s`` and ``z`` that
represent the differentiation and delay operators in continuous and
discrete time. These can be used to create variables that allow algebraic
creation of transfer functions. For example,
>>> s = TransferFunction.s
>>> G = (s + 1)/(s**2 + 2*s + 1)
"""
def __init__(self, *args):
"""TransferFunction(num, den[, dt])
Construct a transfer function.
The default constructor is TransferFunction(num, den), where num and
den are lists of lists of arrays containing polynomial coefficients.
To create a discrete time transfer funtion, use TransferFunction(num,
den, dt) where 'dt' is the sampling time (or True for unspecified
sampling time). To call the copy constructor, call
TransferFunction(sys), where sys is a TransferFunction object
(continuous or discrete).
"""
args = deepcopy(args)
if len(args) == 2:
# The user provided a numerator and a denominator.
(num, den) = args
dt = None
elif len(args) == 3:
# Discrete time transfer function
(num, den, dt) = args
elif len(args) == 1:
# Use the copy constructor.
if not isinstance(args[0], TransferFunction):
raise TypeError("The one-argument constructor can only take \
in a TransferFunction object. Received %s."
% type(args[0]))
num = args[0].num
den = args[0].den
# TODO: not sure this can ever happen since dt is always present
try:
dt = args[0].dt
except NameError: # pragma: no coverage
dt = None
else:
raise ValueError("Needs 1, 2 or 3 arguments; received %i."
% len(args))
num = _clean_part(num)
den = _clean_part(den)
inputs = len(num[0])
outputs = len(num)
# Make sure numerator and denominator matrices have consistent sizes
if inputs != len(den[0]):
raise ValueError(
"The numerator has %i input(s), but the denominator has "
"%i input(s)." % (inputs, len(den[0])))
if outputs != len(den):
raise ValueError(
"The numerator has %i output(s), but the denominator has "
"%i output(s)." % (outputs, len(den)))
# Additional checks/updates on structure of the transfer function
for i in range(outputs):
# Make sure that each row has the same number of columns
if len(num[i]) != inputs:
raise ValueError(
"Row 0 of the numerator matrix has %i elements, but row "
"%i has %i." % (inputs, i, len(num[i])))
if len(den[i]) != inputs:
raise ValueError(
"Row 0 of the denominator matrix has %i elements, but row "
"%i has %i." % (inputs, i, len(den[i])))
# Check for zeros in numerator or denominator
# TODO: Right now these checks are only done during construction.
# It might be worthwhile to think of a way to perform checks if the
# user modifies the transfer function after construction.
for j in range(inputs):
# Check that we don't have any zero denominators.
zeroden = True
for k in den[i][j]:
if k:
zeroden = False
break
if zeroden:
raise ValueError(
"Input %i, output %i has a zero denominator."
% (j + 1, i + 1))
# If we have zero numerators, set the denominator to 1.
zeronum = True
for k in num[i][j]:
if k:
zeronum = False
break
if zeronum:
den[i][j] = ones(1)
LTI.__init__(self, inputs, outputs, dt)
self.num = num
self.den = den
self._truncatecoeff()
def __call__(self, s):
"""Evaluate the system's transfer function for a complex variable
For a SISO transfer function, returns the value of the
transfer function. For a MIMO transfer fuction, returns a
matrix of values evaluated at complex variable s."""
if self.issiso():
# return a scalar
return self.horner(s)[0][0]
else:
# return a matrix
return self.horner(s)
def _truncatecoeff(self):
"""Remove extraneous zero coefficients from num and den.
Check every element of the numerator and denominator matrices, and
truncate leading zeros. For instance, running self._truncatecoeff()
will reduce self.num = [[[0, 0, 1, 2]]] to [[[1, 2]]].
"""
# Beware: this is a shallow copy. This should be okay.
data = [self.num, self.den]
for p in range(len(data)):
for i in range(self.outputs):
for j in range(self.inputs):
# Find the first nontrivial coefficient.
nonzero = None
for k in range(data[p][i][j].size):
if data[p][i][j][k]:
nonzero = k
break
if nonzero is None:
# The array is all zeros.
data[p][i][j] = zeros(1)
else:
# Truncate the trivial coefficients.
data[p][i][j] = data[p][i][j][nonzero:]
[self.num, self.den] = data
def __str__(self, var=None):
"""String representation of the transfer function."""
mimo = self.inputs > 1 or self.outputs > 1
if var is None:
# TODO: replace with standard calls to lti functions
var = 's' if self.dt is None or self.dt == 0 else 'z'
outstr = ""
for i in range(self.inputs):
for j in range(self.outputs):
if mimo:
outstr += "\nInput %i to output %i:" % (i + 1, j + 1)
# Convert the numerator and denominator polynomials to strings.
numstr = _tf_polynomial_to_string(self.num[j][i], var=var)
denstr = _tf_polynomial_to_string(self.den[j][i], var=var)
# Figure out the length of the separating line
dashcount = max(len(numstr), len(denstr))
dashes = '-' * dashcount
# Center the numerator or denominator
if len(numstr) < dashcount:
numstr = (' ' * int(round((dashcount - len(numstr)) / 2)) +
numstr)
if len(denstr) < dashcount:
denstr = (' ' * int(round((dashcount - len(denstr)) / 2)) +
denstr)
outstr += "\n" + numstr + "\n" + dashes + "\n" + denstr + "\n"
# See if this is a discrete time system with specific sampling time
if not (self.dt is None) and type(self.dt) != bool and self.dt > 0:
# TODO: replace with standard calls to lti functions
outstr += "\ndt = " + self.dt.__str__() + "\n"
return outstr
# represent as string, makes display work for IPython
__repr__ = __str__
def _repr_latex_(self, var=None):
"""LaTeX representation of transfer function, for Jupyter notebook"""
mimo = self.inputs > 1 or self.outputs > 1
if var is None:
# ! TODO: replace with standard calls to lti functions
var = 's' if self.dt is None or self.dt == 0 else 'z'
out = ['$$']
if mimo:
out.append(r"\begin{bmatrix}")
for i in range(self.outputs):
for j in range(self.inputs):
# Convert the numerator and denominator polynomials to strings.
numstr = _tf_polynomial_to_string(self.num[i][j], var=var)
denstr = _tf_polynomial_to_string(self.den[i][j], var=var)
numstr = _tf_string_to_latex(numstr, var=var)
denstr = _tf_string_to_latex(denstr, var=var)
out += [r"\frac{", numstr, "}{", denstr, "}"]
if mimo and j < self.outputs - 1:
out.append("&")
if mimo:
out.append(r"\\")
if mimo:
out.append(r" \end{bmatrix}")
# See if this is a discrete time system with specific sampling time
if not (self.dt is None) and type(self.dt) != bool and self.dt > 0:
out += [r"\quad dt = ", str(self.dt)]
out.append("$$")
return ''.join(out)
def __neg__(self):
"""Negate a transfer function."""
num = deepcopy(self.num)
for i in range(self.outputs):
for j in range(self.inputs):
num[i][j] *= -1
return TransferFunction(num, self.den, self.dt)
def __add__(self, other):
"""Add two LTI objects (parallel connection)."""
from .statesp import StateSpace
# Convert the second argument to a transfer function.
if isinstance(other, StateSpace):
other = _convert_to_transfer_function(other)
elif not isinstance(other, TransferFunction):
other = _convert_to_transfer_function(other, inputs=self.inputs,
outputs=self.outputs)
# Check that the input-output sizes are consistent.
if self.inputs != other.inputs:
raise ValueError(
"The first summand has %i input(s), but the second has %i."
% (self.inputs, other.inputs))
if self.outputs != other.outputs:
raise ValueError(
"The first summand has %i output(s), but the second has %i."
% (self.outputs, other.outputs))
# Figure out the sampling time to use
if self.dt is None and other.dt is not None:
dt = other.dt # use dt from second argument
elif (other.dt is None and self.dt is not None) or \
(timebaseEqual(self, other)):
dt = self.dt # use dt from first argument
else:
raise ValueError("Systems have different sampling times")
# Preallocate the numerator and denominator of the sum.
num = [[[] for j in range(self.inputs)] for i in range(self.outputs)]
den = [[[] for j in range(self.inputs)] for i in range(self.outputs)]
for i in range(self.outputs):
for j in range(self.inputs):
num[i][j], den[i][j] = _add_siso(
self.num[i][j], self.den[i][j],
other.num[i][j], other.den[i][j])
return TransferFunction(num, den, dt)
def __radd__(self, other):
"""Right add two LTI objects (parallel connection)."""
return self + other
def __sub__(self, other):
"""Subtract two LTI objects."""
return self + (-other)
def __rsub__(self, other):
"""Right subtract two LTI objects."""
return other + (-self)
def __mul__(self, other):
"""Multiply two LTI objects (serial connection)."""
# Convert the second argument to a transfer function.
if isinstance(other, (int, float, complex, np.number)):
other = _convert_to_transfer_function(other, inputs=self.inputs,
outputs=self.inputs)
else:
other = _convert_to_transfer_function(other)
# Check that the input-output sizes are consistent.
if self.inputs != other.outputs:
raise ValueError(
"C = A * B: A has %i column(s) (input(s)), but B has %i "
"row(s)\n(output(s))." % (self.inputs, other.outputs))
inputs = other.inputs
outputs = self.outputs
# Figure out the sampling time to use
if self.dt is None and other.dt is not None:
dt = other.dt # use dt from second argument
elif (other.dt is None and self.dt is not None) or \
(self.dt == other.dt):
dt = self.dt # use dt from first argument
else:
raise ValueError("Systems have different sampling times")
# Preallocate the numerator and denominator of the sum.
num = [[[0] for j in range(inputs)] for i in range(outputs)]
den = [[[1] for j in range(inputs)] for i in range(outputs)]
# Temporary storage for the summands needed to find the (i, j)th
# element of the product.
num_summand = [[] for k in range(self.inputs)]
den_summand = [[] for k in range(self.inputs)]
# Multiply & add.
for row in range(outputs):
for col in range(inputs):
for k in range(self.inputs):
num_summand[k] = polymul(
self.num[row][k], other.num[k][col])
den_summand[k] = polymul(
self.den[row][k], other.den[k][col])
num[row][col], den[row][col] = _add_siso(
num[row][col], den[row][col],
num_summand[k], den_summand[k])
return TransferFunction(num, den, dt)
def __rmul__(self, other):
"""Right multiply two LTI objects (serial connection)."""
# Convert the second argument to a transfer function.
if isinstance(other, (int, float, complex, np.number)):
other = _convert_to_transfer_function(other, inputs=self.inputs,
outputs=self.inputs)
else:
other = _convert_to_transfer_function(other)
# Check that the input-output sizes are consistent.
if other.inputs != self.outputs:
raise ValueError(
"C = A * B: A has %i column(s) (input(s)), but B has %i "
"row(s)\n(output(s))." % (other.inputs, self.outputs))
inputs = self.inputs
outputs = other.outputs
# Figure out the sampling time to use
if self.dt is None and other.dt is not None:
dt = other.dt # use dt from second argument
elif (other.dt is None and self.dt is not None) \
or (self.dt == other.dt):
dt = self.dt # use dt from first argument
else:
raise ValueError("Systems have different sampling times")
# Preallocate the numerator and denominator of the sum.
num = [[[0] for j in range(inputs)] for i in range(outputs)]
den = [[[1] for j in range(inputs)] for i in range(outputs)]
# Temporary storage for the summands needed to find the
# (i, j)th element
# of the product.
num_summand = [[] for k in range(other.inputs)]
den_summand = [[] for k in range(other.inputs)]
for i in range(outputs): # Iterate through rows of product.
for j in range(inputs): # Iterate through columns of product.
for k in range(other.inputs): # Multiply & add.
num_summand[k] = polymul(other.num[i][k], self.num[k][j])
den_summand[k] = polymul(other.den[i][k], self.den[k][j])
num[i][j], den[i][j] = _add_siso(
num[i][j], den[i][j],
num_summand[k], den_summand[k])
return TransferFunction(num, den, dt)
# TODO: Division of MIMO transfer function objects is not written yet.
def __truediv__(self, other):
"""Divide two LTI objects."""
if isinstance(other, (int, float, complex, np.number)):
other = _convert_to_transfer_function(
other, inputs=self.inputs,
outputs=self.inputs)
else:
other = _convert_to_transfer_function(other)
if (self.inputs > 1 or self.outputs > 1 or
other.inputs > 1 or other.outputs > 1):
raise NotImplementedError(
"TransferFunction.__truediv__ is currently \
implemented only for SISO systems.")
# Figure out the sampling time to use
if self.dt is None and other.dt is not None:
dt = other.dt # use dt from second argument
elif (other.dt is None and self.dt is not None) or \
(self.dt == other.dt):
dt = self.dt # use dt from first argument
else:
raise ValueError("Systems have different sampling times")
num = polymul(self.num[0][0], other.den[0][0])
den = polymul(self.den[0][0], other.num[0][0])
return TransferFunction(num, den, dt)
# TODO: Remove when transition to python3 complete
def __div__(self, other):
return TransferFunction.__truediv__(self, other)
# TODO: Division of MIMO transfer function objects is not written yet.
def __rtruediv__(self, other):
"""Right divide two LTI objects."""
if isinstance(other, (int, float, complex, np.number)):
other = _convert_to_transfer_function(
other, inputs=self.inputs,
outputs=self.inputs)
else:
other = _convert_to_transfer_function(other)
if (self.inputs > 1 or self.outputs > 1 or
other.inputs > 1 or other.outputs > 1):
raise NotImplementedError(
"TransferFunction.__rtruediv__ is currently implemented only "
"for SISO systems.")
return other / self
# TODO: Remove when transition to python3 complete
def __rdiv__(self, other):
return TransferFunction.__rtruediv__(self, other)
def __pow__(self, other):
if not type(other) == int:
raise ValueError("Exponent must be an integer")
if other == 0:
return TransferFunction([1], [1]) # unity
if other > 0:
return self * (self**(other - 1))
if other < 0:
return (TransferFunction([1], [1]) / self) * (self**(other + 1))
def __getitem__(self, key):
key1, key2 = key
# pre-process
if isinstance(key1, int):
key1 = slice(key1, key1 + 1, 1)
if isinstance(key2, int):
key2 = slice(key2, key2 + 1, 1)
# dim1
start1, stop1, step1 = key1.start, key1.stop, key1.step
if step1 is None:
step1 = 1
if start1 is None:
start1 = 0
if stop1 is None:
stop1 = len(self.num)
# dim1
start2, stop2, step2 = key2.start, key2.stop, key2.step
if step2 is None:
step2 = 1
if start2 is None:
start2 = 0
if stop2 is None:
stop2 = len(self.num[0])
num = []
den = []
for i in range(start1, stop1, step1):
num_i = []
den_i = []
for j in range(start2, stop2, step2):
num_i.append(self.num[i][j])
den_i.append(self.den[i][j])
num.append(num_i)
den.append(den_i)
if self.isctime():
return TransferFunction(num, den)
else:
return TransferFunction(num, den, self.dt)
def evalfr(self, omega):
"""Evaluate a transfer function at a single angular frequency.
self._evalfr(omega) returns the value of the transfer function
matrix with input value s = i * omega.
"""
warn("TransferFunction.evalfr(omega) will be deprecated in a "
"future release of python-control; use evalfr(sys, omega*1j) "
"instead", PendingDeprecationWarning)
return self._evalfr(omega)
def _evalfr(self, omega):
"""Evaluate a transfer function at a single angular frequency."""
# TODO: implement for discrete time systems
if isdtime(self, strict=True):
# Convert the frequency to discrete time
dt = timebase(self)
s = exp(1.j * omega * dt)
if np.any(omega * dt > pi):
warn("_evalfr: frequency evaluation above Nyquist frequency")
else:
s = 1.j * omega
return self.horner(s)
def horner(self, s):
"""Evaluate the systems's transfer function for a complex variable
Returns a matrix of values evaluated at complex variable s.
"""
# Preallocate the output.
if getattr(s, '__iter__', False):
out = empty((self.outputs, self.inputs, len(s)), dtype=complex)
else:
out = empty((self.outputs, self.inputs), dtype=complex)
for i in range(self.outputs):
for j in range(self.inputs):
out[i][j] = (polyval(self.num[i][j], s) /
polyval(self.den[i][j], s))
return out
# Method for generating the frequency response of the system
def freqresp(self, omega):
"""Evaluate a transfer function at a list of angular frequencies.
mag, phase, omega = self.freqresp(omega)
reports the value of the magnitude, phase, and angular frequency of
the transfer function matrix evaluated at s = i * omega, where omega
is a list of angular frequencies, and is a sorted version of the input
omega.
"""
# Preallocate outputs.
numfreq = len(omega)
mag = empty((self.outputs, self.inputs, numfreq))
phase = empty((self.outputs, self.inputs, numfreq))
# Figure out the frequencies
omega.sort()
if isdtime(self, strict=True):
dt = timebase(self)
slist = np.array([exp(1.j * w * dt) for w in omega])
if max(omega) * dt > pi:
warn("freqresp: frequency evaluation above Nyquist frequency")
else:
slist = np.array([1j * w for w in omega])
# Compute frequency response for each input/output pair
for i in range(self.outputs):
for j in range(self.inputs):
fresp = (polyval(self.num[i][j], slist) /
polyval(self.den[i][j], slist))
mag[i, j, :] = abs(fresp)
phase[i, j, :] = angle(fresp)
return mag, phase, omega
def pole(self):
"""Compute the poles of a transfer function."""
num, den, denorder = self._common_den()
rts = []
for d, o in zip(den, denorder):
rts.extend(roots(d[:o + 1]))
return np.array(rts)
def zero(self):
"""Compute the zeros of a transfer function."""
if self.inputs > 1 or self.outputs > 1:
raise NotImplementedError(
"TransferFunction.zero is currently only implemented "
"for SISO systems.")
else:
# for now, just give zeros of a SISO tf
return roots(self.num[0][0])
def feedback(self, other=1, sign=-1):
"""Feedback interconnection between two LTI objects."""
other = _convert_to_transfer_function(other)
if (self.inputs > 1 or self.outputs > 1 or
other.inputs > 1 or other.outputs > 1):
# TODO: MIMO feedback
raise NotImplementedError(
"TransferFunction.feedback is currently only implemented "
"for SISO functions.")
# Figure out the sampling time to use
if self.dt is None and other.dt is not None:
dt = other.dt # use dt from second argument
elif (other.dt is None and self.dt is not None) or \
(self.dt == other.dt):
dt = self.dt # use dt from first argument
else:
raise ValueError("Systems have different sampling times")
num1 = self.num[0][0]
den1 = self.den[0][0]
num2 = other.num[0][0]
den2 = other.den[0][0]
num = polymul(num1, den2)
den = polyadd(polymul(den2, den1), -sign * polymul(num2, num1))
return TransferFunction(num, den, dt)
# For MIMO or SISO systems, the analytic expression is
# self / (1 - sign * other * self)
# But this does not work correctly because the state size will be too
# large.
def minreal(self, tol=None):
"""Remove cancelling pole/zero pairs from a transfer function"""
# based on octave minreal
# default accuracy
from sys import float_info
sqrt_eps = sqrt(float_info.epsilon)
# pre-allocate arrays
num = [[[] for j in range(self.inputs)] for i in range(self.outputs)]
den = [[[] for j in range(self.inputs)] for i in range(self.outputs)]
for i in range(self.outputs):
for j in range(self.inputs):
# split up in zeros, poles and gain
newzeros = []
zeros = roots(self.num[i][j])
poles = roots(self.den[i][j])
gain = self.num[i][j][0] / self.den[i][j][0]
# check all zeros
for z in zeros:
t = tol or \
1000 * max(float_info.epsilon, abs(z) * sqrt_eps)
idx = where(abs(z - poles) < t)[0]
if len(idx):
# cancel this zero against one of the poles
poles = delete(poles, idx[0])
else:
# keep this zero
newzeros.append(z)
# poly([]) returns a scalar, but we always want a 1d array
num[i][j] = np.atleast_1d(gain * real(poly(newzeros)))
den[i][j] = np.atleast_1d(real(poly(poles)))
# end result
return TransferFunction(num, den, self.dt)
def returnScipySignalLTI(self):
"""Return a list of a list of scipy.signal.lti objects.
For instance,
>>> out = tfobject.returnScipySignalLTI()
>>> out[3][5]
is a signal.scipy.lti object corresponding to the
transfer function from the 6th input to the 4th output.
"""
# TODO: implement for discrete time systems
if self.dt != 0 and self.dt is not None:
raise NotImplementedError("Function not \
implemented in discrete time")
# Preallocate the output.
out = [[[] for j in range(self.inputs)] for i in range(self.outputs)]
for i in range(self.outputs):
for j in range(self.inputs):
out[i][j] = lti(self.num[i][j], self.den[i][j])
return out
def _common_den(self, imag_tol=None):
"""
Compute MIMO common denominators; return them and adjusted numerators.
This function computes the denominators per input containing all
the poles of sys.den, and reports it as the array den. The
output numerator array num is modified to use the common
denominator for this input/column; the coefficient arrays are also
padded with zeros to be the same size for all num/den.
Parameters
----------
imag_tol: float
Threshold for the imaginary part of a root to use in detecting
complex poles
Returns
-------
num: array
n by n by kd where n = max(sys.outputs,sys.inputs)
kd = max(denorder)+1
Multi-dimensional array of numerator coefficients. num[i,j]
gives the numerator coefficient array for the ith output and jth
input; padded for use in td04ad ('C' option); matches the
denorder order; highest coefficient starts on the left.
den: array
sys.inputs by kd
Multi-dimensional array of coefficients for common denominator
polynomial, one row per input. The array is prepared for use in
slycot td04ad, the first element is the highest-order polynomial
coefficient of s, matching the order in denorder. If denorder <
number of columns in den, the den is padded with zeros.
denorder: array of int, orders of den, one per input
Examples
--------
>>> num, den, denorder = sys._common_den()
"""
# Machine precision for floats.
eps = finfo(float).eps
real_tol = sqrt(eps * self.inputs * self.outputs)
# The tolerance to use in deciding if a pole is complex
if (imag_tol is None):
imag_tol = 2 * real_tol
# A list to keep track of cumulative poles found as we scan
# self.den[..][..]
poles = [[] for j in range(self.inputs)]
# RvP, new implementation 180526, issue #194
# BG, modification, issue #343, PR #354
# pre-calculate the poles for all num, den
# has zeros, poles, gain, list for pole indices not in den,
# number of poles known at the time analyzed
# do not calculate minreal. Rory's hint .minreal()
poleset = []
for i in range(self.outputs):
poleset.append([])
for j in range(self.inputs):
if abs(self.num[i][j]).max() <= eps:
poleset[-1].append([array([], dtype=float),
roots(self.den[i][j]), 0.0, [], 0])
else:
z, p, k = tf2zpk(self.num[i][j], self.den[i][j])
poleset[-1].append([z, p, k, [], 0])
# collect all individual poles
for j in range(self.inputs):
for i in range(self.outputs):
currentpoles = poleset[i][j][1]
nothave = ones(currentpoles.shape, dtype=bool)
for ip, p in enumerate(poles[j]):
collect = (np.isclose(currentpoles.real, p.real,
atol=real_tol) &
np.isclose(currentpoles.imag, p.imag,
atol=imag_tol) &
nothave)
if np.any(collect):
# mark first found pole as already collected
nothave[nonzero(collect)[0][0]] = False
else:
# remember id of pole not in tf
poleset[i][j][3].append(ip)
for h, c in zip(nothave, currentpoles):
if h:
if abs(c.imag) < imag_tol:
c = c.real
poles[j].append(c)
# remember how many poles now known
poleset[i][j][4] = len(poles[j])
# figure out maximum number of poles, for sizing the den
maxindex = max([len(p) for p in poles])
den = zeros((self.inputs, maxindex + 1), dtype=float)
num = zeros((max(1, self.outputs, self.inputs),
max(1, self.outputs, self.inputs),
maxindex + 1),
dtype=float)
denorder = zeros((self.inputs,), dtype=int)
for j in range(self.inputs):
if not len(poles[j]):
# no poles matching this input; only one or more gains
den[j, 0] = 1.0
for i in range(self.outputs):
num[i, j, 0] = poleset[i][j][2]
else:
# create the denominator matching this input
# coefficients should be padded on right, ending at maxindex
maxindex = len(poles[j])
den[j, :maxindex+1] = poly(poles[j])
denorder[j] = maxindex
# now create the numerator, also padded on the right
for i in range(self.outputs):
# start with the current set of zeros for this output
nwzeros = list(poleset[i][j][0])
# add all poles not found in the original denominator,
# and the ones later added from other denominators
for ip in chain(poleset[i][j][3],
range(poleset[i][j][4], maxindex)):
nwzeros.append(poles[j][ip])
numpoly = poleset[i][j][2] * np.atleast_1d(poly(nwzeros))
# numerator polynomial should be padded on left and right
# ending at maxindex to line up with what td04ad expects.
num[i, j, maxindex+1-len(numpoly):maxindex+1] = numpoly
# print(num[i, j])
return num, den, denorder
def sample(self, Ts, method='zoh', alpha=None):
"""Convert a continuous-time system to discrete time
Creates a discrete-time system from a continuous-time system by
sampling. Multiple methods of conversion are supported.
Parameters
----------
Ts : float
Sampling period
method : {"gbt", "bilinear", "euler", "backward_diff",
"zoh", "matched"}
Method to use for sampling:
* gbt: generalized bilinear transformation
* bilinear: Tustin's approximation ("gbt" with alpha=0.5)
* euler: Euler (or forward difference) method ("gbt" with alpha=0)
* backward_diff: Backwards difference ("gbt" with alpha=1.0)
* zoh: zero-order hold (default)
alpha : float within [0, 1]
The generalized bilinear transformation weighting parameter, which
should only be specified with method="gbt", and is ignored
otherwise.
Returns
-------
sysd : StateSpace system
Discrete time system, with sampling rate Ts
Notes
-----
1. Available only for SISO systems
2. Uses the command `cont2discrete` from `scipy.signal`
Examples
--------
>>> sys = TransferFunction(1, [1,1])
>>> sysd = sys.sample(0.5, method='bilinear')
"""
if not self.isctime():
raise ValueError("System must be continuous time system")
if not self.issiso():
raise NotImplementedError("MIMO implementation not available")
if method == "matched":
return _c2d_matched(self, Ts)
sys = (self.num[0][0], self.den[0][0])
numd, dend, dt = cont2discrete(sys, Ts, method, alpha)
return TransferFunction(numd[0, :], dend, dt)
def dcgain(self):
"""Return the zero-frequency (or DC) gain
For a continous-time transfer function G(s), the DC gain is G(0)
For a discrete-time transfer function G(z), the DC gain is G(1)
Returns
-------
gain : ndarray
The zero-frequency gain
"""
if self.isctime():
return self._dcgain_cont()
else:
return self(1)
def _dcgain_cont(self):
"""_dcgain_cont() -> DC gain as matrix or scalar
Special cased evaluation at 0 for continuous-time systems."""
gain = np.empty((self.outputs, self.inputs), dtype=float)
for i in range(self.outputs):
for j in range(self.inputs):
num = self.num[i][j][-1]
den = self.den[i][j][-1]
if den:
gain[i][j] = num / den
else:
if num:
# numerator nonzero: infinite gain
gain[i][j] = np.inf
else:
# numerator is zero too: give up
gain[i][j] = np.nan
return np.squeeze(gain)
# c2d function contributed by Benjamin White, Oct 2012
def _c2d_matched(sysC, Ts):
# Pole-zero match method of continuous to discrete time conversion
szeros, spoles, sgain = tf2zpk(sysC.num[0][0], sysC.den[0][0])
zzeros = [0] * len(szeros)
zpoles = [0] * len(spoles)
pregainnum = [0] * len(szeros)
pregainden = [0] * len(spoles)
for idx, s in enumerate(szeros):
sTs = s * Ts
z = exp(sTs)
zzeros[idx] = z
pregainnum[idx] = 1 - z
for idx, s in enumerate(spoles):
sTs = s * Ts
z = exp(sTs)
zpoles[idx] = z
pregainden[idx] = 1 - z
zgain = np.multiply.reduce(pregainnum) / np.multiply.reduce(pregainden)
gain = sgain / zgain
sysDnum, sysDden = zpk2tf(zzeros, zpoles, gain)
return TransferFunction(sysDnum, sysDden, Ts)
# Utility function to convert a transfer function polynomial to a string
# Borrowed from poly1d library
def _tf_polynomial_to_string(coeffs, var='s'):
"""Convert a transfer function polynomial to a string"""
thestr = "0"
# Compute the number of coefficients
N = len(coeffs) - 1
for k in range(len(coeffs)):
coefstr = '%.4g' % abs(coeffs[k])
if coefstr[-4:] == '0000':
coefstr = coefstr[:-5]
power = (N - k)
if power == 0:
if coefstr != '0':
newstr = '%s' % (coefstr,)
else:
if k == 0:
newstr = '0'
else:
newstr = ''
elif power == 1:
if coefstr == '0':
newstr = ''
elif coefstr == '1':
newstr = var
else:
newstr = '%s %s' % (coefstr, var)
else:
if coefstr == '0':
newstr = ''
elif coefstr == '1':
newstr = '%s^%d' % (var, power,)
else:
newstr = '%s %s^%d' % (coefstr, var, power)
if k > 0:
if newstr != '':
if coeffs[k] < 0:
thestr = "%s - %s" % (thestr, newstr)
else:
thestr = "%s + %s" % (thestr, newstr)
elif (k == 0) and (newstr != '') and (coeffs[k] < 0):
thestr = "-%s" % (newstr,)
else:
thestr = newstr
return thestr
def _tf_string_to_latex(thestr, var='s'):
""" make sure to superscript all digits in a polynomial string
and convert float coefficients in scientific notation
to prettier LaTeX representation """
# TODO: make the multiplication sign configurable
expmul = r' \\times'
thestr = sub(var + r'\^(\d{2,})', var + r'^{\1}', thestr)
thestr = sub(r'[eE]\+0*(\d+)', expmul + r' 10^{\1}', thestr)
thestr = sub(r'[eE]\-0*(\d+)', expmul + r' 10^{-\1}', thestr)
return thestr
def _add_siso(num1, den1, num2, den2):
"""Return num/den = num1/den1 + num2/den2.
Each numerator and denominator is a list of polynomial coefficients.
"""
num = polyadd(polymul(num1, den2), polymul(num2, den1))
den = polymul(den1, den2)
return num, den
def _convert_to_transfer_function(sys, **kw):
"""Convert a system to transfer function form (if needed).
If sys is already a transfer function, then it is returned. If sys is a
state space object, then it is converted to a transfer function and
returned. If sys is a scalar, then the number of inputs and outputs can be
specified manually, as in:
>>> sys = _convert_to_transfer_function(3.) # Assumes inputs = outputs = 1
>>> sys = _convert_to_transfer_function(1., inputs=3, outputs=2)
In the latter example, sys's matrix transfer function is [[1., 1., 1.]
[1., 1., 1.]].
If sys is an array-like type, then it is converted to a constant-gain
transfer function.
>>> sys = _convert_to_transfer_function([[1., 0.], [2., 3.]])
In this example, the numerator matrix will be
[[[1.0], [0.0]], [[2.0], [3.0]]]
and the denominator matrix [[[1.0], [1.0]], [[1.0], [1.0]]]
"""
from .statesp import StateSpace
if isinstance(sys, TransferFunction):
if len(kw):
raise TypeError("If sys is a TransferFunction, " +
"_convertToTransferFunction cannot take keywords.")
return sys
elif isinstance(sys, StateSpace):
if 0 == sys.states:
# Slycot doesn't like static SS->TF conversion, so handle
# it first. Can't join this with the no-Slycot branch,
# since that doesn't handle general MIMO systems
num = [[[sys.D[i, j]] for j in range(sys.inputs)]
for i in range(sys.outputs)]
den = [[[1.] for j in range(sys.inputs)]
for i in range(sys.outputs)]
else:
try:
from slycot import tb04ad
if len(kw):
raise TypeError(
"If sys is a StateSpace, " +
"_convertToTransferFunction cannot take keywords.")
# Use Slycot to make the transformation
# Make sure to convert system matrices to numpy arrays
tfout = tb04ad(
sys.states, sys.inputs, sys.outputs, array(sys.A),
array(sys.B), array(sys.C), array(sys.D), tol1=0.0)
# Preallocate outputs.
num = [[[] for j in range(sys.inputs)]
for i in range(sys.outputs)]
den = [[[] for j in range(sys.inputs)]
for i in range(sys.outputs)]
for i in range(sys.outputs):
for j in range(sys.inputs):
num[i][j] = list(tfout[6][i, j, :])
# Each transfer function matrix row
# has a common denominator.
den[i][j] = list(tfout[5][i, :])
except ImportError:
# If slycot is not available, use signal.lti (SISO only)
if sys.inputs != 1 or sys.outputs != 1:
raise TypeError("No support for MIMO without slycot.")
# Do the conversion using sp.signal.ss2tf
# Note that this returns a 2D array for the numerator
num, den = sp.signal.ss2tf(sys.A, sys.B, sys.C, sys.D)
num = squeeze(num) # Convert to 1D array
den = squeeze(den) # Probably not needed
return TransferFunction(num, den, sys.dt)
elif isinstance(sys, (int, float, complex, np.number)):
if "inputs" in kw:
inputs = kw["inputs"]
else:
inputs = 1
if "outputs" in kw:
outputs = kw["outputs"]
else:
outputs = 1
num = [[[sys] for j in range(inputs)] for i in range(outputs)]
den = [[[1] for j in range(inputs)] for i in range(outputs)]
return TransferFunction(num, den)
# If this is array-like, try to create a constant feedthrough
try:
D = array(sys)
outputs, inputs = D.shape
num = [[[D[i, j]] for j in range(inputs)] for i in range(outputs)]
den = [[[1] for j in range(inputs)] for i in range(outputs)]
return TransferFunction(num, den)
except Exception as e:
print("Failure to assume argument is matrix-like in"
" _convertToTransferFunction, result %s" % e)
raise TypeError("Can't convert given type to TransferFunction system.")
def tf(*args):
"""tf(num, den[, dt])
Create a transfer function system. Can create MIMO systems.
The function accepts either 1, 2, or 3 parameters:
``tf(sys)``
Convert a linear system into transfer function form. Always creates
a new system, even if sys is already a TransferFunction object.
``tf(num, den)``
Create a transfer function system from its numerator and denominator
polynomial coefficients.
If `num` and `den` are 1D array_like objects, the function creates a
SISO system.
To create a MIMO system, `num` and `den` need to be 2D nested lists
of array_like objects. (A 3 dimensional data structure in total.)
(For details see note below.)
``tf(num, den, dt)``
Create a discrete time transfer function system; dt can either be a
positive number indicating the sampling time or 'True' if no
specific timebase is given.
``tf('s')`` or ``tf('z')``
Create a transfer function representing the differential operator
('s') or delay operator ('z').
Parameters
----------
sys: LTI (StateSpace or TransferFunction)
A linear system
num: array_like, or list of list of array_like
Polynomial coefficients of the numerator
den: array_like, or list of list of array_like
Polynomial coefficients of the denominator
Returns
-------
out: :class:`TransferFunction`
The new linear system
Raises
------
ValueError
if `num` and `den` have invalid or unequal dimensions
TypeError
if `num` or `den` are of incorrect type
See Also
--------
TransferFunction
ss
ss2tf
tf2ss
Notes
-----
``num[i][j]`` contains the polynomial coefficients of the numerator
for the transfer function from the (j+1)st input to the (i+1)st output.
``den[i][j]`` works the same way.
The list ``[2, 3, 4]`` denotes the polynomial :math:`2s^2 + 3s + 4`.
The special forms ``tf('s')`` and ``tf('z')`` can be used to create
transfer functions for differentiation and unit delays.
Examples
--------
>>> # Create a MIMO transfer function object
>>> # The transfer function from the 2nd input to the 1st output is
>>> # (3s + 4) / (6s^2 + 5s + 4).
>>> num = [[[1., 2.], [3., 4.]], [[5., 6.], [7., 8.]]]
>>> den = [[[9., 8., 7.], [6., 5., 4.]], [[3., 2., 1.], [-1., -2., -3.]]]
>>> sys1 = tf(num, den)
>>> # Create a variable 's' to allow algebra operations for SISO systems
>>> s = tf('s')
>>> G = (s + 1)/(s**2 + 2*s + 1)
>>> # Convert a StateSpace to a TransferFunction object.
>>> sys_ss = ss("1. -2; 3. -4", "5.; 7", "6. 8", "9.")
>>> sys2 = tf(sys1)
"""
if len(args) == 2 or len(args) == 3:
return TransferFunction(*args)
elif len(args) == 1:
# Look for special cases defining differential/delay operator
if args[0] == 's':
return TransferFunction.s
elif args[0] == 'z':
return TransferFunction.z
from .statesp import StateSpace
sys = args[0]
if isinstance(sys, StateSpace):
return ss2tf(sys)
elif isinstance(sys, TransferFunction):
return deepcopy(sys)
else:
raise TypeError("tf(sys): sys must be a StateSpace or "
"TransferFunction object. It is %s." % type(sys))
else:
raise ValueError("Needs 1 or 2 arguments; received %i." % len(args))
def ss2tf(*args):
"""ss2tf(sys)
Transform a state space system to a transfer function.
The function accepts either 1 or 4 parameters:
``ss2tf(sys)``
Convert a linear system into space system form. Always creates a
new system, even if sys is already a StateSpace object.
``ss2tf(A, B, C, D)``
Create a state space system from the matrices of its state and
output equations.
For details see: :func:`ss`
Parameters
----------
sys: StateSpace
A linear system
A: array_like or string
System matrix
B: array_like or string
Control matrix
C: array_like or string
Output matrix
D: array_like or string
Feedthrough matrix
Returns
-------
out: TransferFunction
New linear system in transfer function form
Raises
------
ValueError
if matrix sizes are not self-consistent, or if an invalid number of
arguments is passed in
TypeError
if `sys` is not a StateSpace object
See Also
--------
tf
ss
tf2ss
Examples
--------
>>> A = [[1., -2], [3, -4]]
>>> B = [[5.], [7]]
>>> C = [[6., 8]]
>>> D = [[9.]]
>>> sys1 = ss2tf(A, B, C, D)
>>> sys_ss = ss(A, B, C, D)
>>> sys2 = ss2tf(sys_ss)
"""
from .statesp import StateSpace
if len(args) == 4 or len(args) == 5:
# Assume we were given the A, B, C, D matrix and (optional) dt
return _convert_to_transfer_function(StateSpace(*args))
elif len(args) == 1:
sys = args[0]
if isinstance(sys, StateSpace):
return _convert_to_transfer_function(sys)
else:
raise TypeError(
"ss2tf(sys): sys must be a StateSpace object. It is %s."
% type(sys))
else:
raise ValueError("Needs 1 or 4 arguments; received %i." % len(args))
def tfdata(sys):
"""
Return transfer function data objects for a system
Parameters
----------
sys: LTI (StateSpace, or TransferFunction)
LTI system whose data will be returned
Returns
-------
(num, den): numerator and denominator arrays
Transfer function coefficients (SISO only)
"""
tf = _convert_to_transfer_function(sys)
return tf.num, tf.den
def _clean_part(data):
"""
Return a valid, cleaned up numerator or denominator
for the TransferFunction class.
Parameters
----------
data: numerator or denominator of a transfer function.
Returns
-------
data: list of lists of ndarrays, with int converted to float
"""
valid_types = (int, float, complex, np.number)
valid_collection = (list, tuple, ndarray)
if (isinstance(data, valid_types) or
(isinstance(data, ndarray) and data.ndim == 0)):
# Data is a scalar (including 0d ndarray)
data = [[array([data])]]
elif (isinstance(data, ndarray) and data.ndim == 3 and
isinstance(data[0, 0, 0], valid_types)):
data = [[array(data[i, j])
for j in range(data.shape[1])]
for i in range(data.shape[0])]
elif (isinstance(data, valid_collection) and
all([isinstance(d, valid_types) for d in data])):
data = [[array(data)]]
elif (isinstance(data, (list, tuple)) and
isinstance(data[0], (list, tuple)) and
(isinstance(data[0][0], valid_collection) and
all([isinstance(d, valid_types) for d in data[0][0]]))):
data = list(data)
for j in range(len(data)):
data[j] = list(data[j])
for k in range(len(data[j])):
data[j][k] = array(data[j][k])
else:
# If the user passed in anything else, then it's unclear what
# the meaning is.
raise TypeError(
"The numerator and denominator inputs must be scalars or vectors "
"(for\nSISO), or lists of lists of vectors (for SISO or MIMO).")
# Check for coefficients that are ints and convert to floats
for i in range(len(data)):
for j in range(len(data[i])):
for k in range(len(data[i][j])):
if isinstance(data[i][j][k], (int, np.int)):
data[i][j][k] = float(data[i][j][k])
return data
# Define constants to represent differentiation, unit delay
TransferFunction.s = TransferFunction([1, 0], [1], 0)
TransferFunction.z = TransferFunction([1, 0], [1], True)