Skip to content

Fix inconsistencies in frequency response at poles #542

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 5 commits into from
Feb 24, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions control/lti.py
Original file line number Diff line number Diff line change
Expand Up @@ -644,8 +644,10 @@ def dcgain(sys):
Returns
-------
gain : ndarray
The zero-frequency gain, or np.nan if the system has a pole
at the origin
The zero-frequency gain, or (inf + nanj) if the system has a pole at
the origin, (nan + nanj) if there is a pole/zero cancellation at the
origin.

"""
return sys.dcgain()

Expand Down
3 changes: 1 addition & 2 deletions control/margins.py
Original file line number Diff line number Diff line change
Expand Up @@ -294,8 +294,7 @@ def stability_margins(sysdata, returnall=False, epsw=0.0):
num_iw, den_iw = _poly_iw(sys)
# frequency for gain margin: phase crosses -180 degrees
w_180 = _poly_iw_real_crossing(num_iw, den_iw, epsw)
with np.errstate(all='ignore'): # den=0 is okay
w180_resp = sys(1J * w_180)
w180_resp = sys(1J * w_180, warn_infinite=False) # den=0 is okay

# frequency for phase margin : gain crosses magnitude 1
wc = _poly_iw_mag1_crossing(num_iw, den_iw, epsw)
Expand Down
60 changes: 37 additions & 23 deletions control/statesp.py
Original file line number Diff line number Diff line change
Expand Up @@ -668,7 +668,7 @@ def __rdiv__(self, other):
raise NotImplementedError(
"StateSpace.__rdiv__ is not implemented yet.")

def __call__(self, x, squeeze=None):
def __call__(self, x, squeeze=None, warn_infinite=True):
"""Evaluate system's transfer function at complex frequency.

Returns the complex frequency response `sys(x)` where `x` is `s` for
Expand All @@ -689,6 +689,8 @@ def __call__(self, x, squeeze=None):
keep all indices (output, input and, if omega is array_like,
frequency) even if the system is SISO. The default value can be
set using config.defaults['control.squeeze_frequency_response'].
warn_infinite : bool, optional
If set to `False`, don't warn if frequency response is infinite.

Returns
-------
Expand All @@ -702,7 +704,7 @@ def __call__(self, x, squeeze=None):

"""
# Use Slycot if available
out = self.horner(x)
out = self.horner(x, warn_infinite=warn_infinite)
return _process_frequency_response(self, x, out, squeeze=squeeze)

def slycot_laub(self, x):
Expand All @@ -723,7 +725,9 @@ def slycot_laub(self, x):
Frequency response
"""
from slycot import tb05ad
x_arr = np.atleast_1d(x) # array-like version of x

# Make sure the argument is a 1D array of complex numbers
x_arr = np.atleast_1d(x).astype(complex, copy=False)

# Make sure that we are operating on a simple list
if len(x_arr.shape) > 1:
Expand Down Expand Up @@ -758,7 +762,7 @@ def slycot_laub(self, x):
out[:, :, kk+1] = result[0] + self.D
return out

def horner(self, x):
def horner(self, x, warn_infinite=True):
"""Evaluate system's transfer function at complex frequency
using Laub's or Horner's method.

Expand Down Expand Up @@ -788,21 +792,38 @@ def horner(self, x):
except (ImportError, Exception):
# Fall back because either Slycot unavailable or cannot handle
# certain cases.
x_arr = np.atleast_1d(x) # force to be an array

# Make sure the argument is a 1D array of complex numbers
x_arr = np.atleast_1d(x).astype(complex, copy=False)

# Make sure that we are operating on a simple list
if len(x_arr.shape) > 1:
raise ValueError("input list must be 1D")

# Preallocate
out = empty((self.noutputs, self.ninputs, len(x_arr)), dtype=complex)
out = empty((self.noutputs, self.ninputs, len(x_arr)),
dtype=complex)

#TODO: can this be vectorized?
for idx, x_idx in enumerate(x_arr):
out[:,:,idx] = \
np.dot(self.C,
try:
out[:,:,idx] = np.dot(
self.C,
solve(x_idx * eye(self.nstates) - self.A, self.B)) \
+ self.D
+ self.D
except LinAlgError:
# Issue a warning messsage, for consistency with xferfcn
if warn_infinite:
warn("singular matrix in frequency response",
RuntimeWarning)

# Evaluating at a pole. Return value depends if there
# is a zero at the same point or not.
if x_idx in self.zero():
out[:,:,idx] = complex(np.nan, np.nan)
else:
out[:,:,idx] = complex(np.inf, np.nan)

return out

def freqresp(self, omega):
Expand Down Expand Up @@ -1183,7 +1204,7 @@ def sample(self, Ts, method='zoh', alpha=None, prewarp_frequency=None):
Ad, Bd, C, D, _ = cont2discrete(sys, Twarp, method, alpha)
return StateSpace(Ad, Bd, C, D, Ts)

def dcgain(self):
def dcgain(self, warn_infinite=False):
"""Return the zero-frequency gain

The zero-frequency gain of a continuous-time state-space
Expand All @@ -1198,20 +1219,13 @@ def dcgain(self):
Returns
-------
gain : ndarray
An array of shape (outputs,inputs); the array will either
be the zero-frequency (or DC) gain, or, if the frequency
response is singular, the array will be filled with np.nan.
An array of shape (outputs,inputs); the array will either be the
zero-frequency (or DC) gain, or, if the frequency response is
singular, the array will be filled with (inf + nanj).

"""
try:
if self.isctime():
gain = np.asarray(self.D -
self.C.dot(np.linalg.solve(self.A, self.B)))
else:
gain = np.squeeze(self.horner(1))
except LinAlgError:
# eigenvalue at DC
gain = np.tile(np.nan, (self.noutputs, self.ninputs))
return np.squeeze(gain)
return self(0, warn_infinite=warn_infinite) if self.isctime() \
else self(1, warn_infinite=warn_infinite)

def _isstatic(self):
"""True if and only if the system has no dynamics, that is,
Expand Down
137 changes: 137 additions & 0 deletions control/tests/freqresp_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -364,3 +364,140 @@ def test_phase_wrap(TF, wrap_phase, min_phase, max_phase):
mag, phase, omega = ctrl.bode(TF, wrap_phase=wrap_phase)
assert(min(phase) >= min_phase)
assert(max(phase) <= max_phase)


def test_freqresp_warn_infinite():
"""Test evaluation warnings for transfer functions w/ pole at the origin"""
sys_finite = ctrl.tf([1], [1, 0.01])
sys_infinite = ctrl.tf([1], [1, 0.01, 0])

# Transfer function with finite zero frequency gain
np.testing.assert_almost_equal(sys_finite(0), 100)
np.testing.assert_almost_equal(sys_finite(0, warn_infinite=False), 100)
np.testing.assert_almost_equal(sys_finite(0, warn_infinite=True), 100)

# Transfer function with infinite zero frequency gain
with pytest.warns(RuntimeWarning, match="divide by zero"):
np.testing.assert_almost_equal(
sys_infinite(0), complex(np.inf, np.nan))
with pytest.warns(RuntimeWarning, match="divide by zero"):
np.testing.assert_almost_equal(
sys_infinite(0, warn_infinite=True), complex(np.inf, np.nan))
np.testing.assert_almost_equal(
sys_infinite(0, warn_infinite=False), complex(np.inf, np.nan))

# Switch to state space
sys_finite = ctrl.tf2ss(sys_finite)
sys_infinite = ctrl.tf2ss(sys_infinite)

# State space system with finite zero frequency gain
np.testing.assert_almost_equal(sys_finite(0), 100)
np.testing.assert_almost_equal(sys_finite(0, warn_infinite=False), 100)
np.testing.assert_almost_equal(sys_finite(0, warn_infinite=True), 100)

# State space system with infinite zero frequency gain
with pytest.warns(RuntimeWarning, match="singular matrix"):
np.testing.assert_almost_equal(
sys_infinite(0), complex(np.inf, np.nan))
with pytest.warns(RuntimeWarning, match="singular matrix"):
np.testing.assert_almost_equal(
sys_infinite(0, warn_infinite=True), complex(np.inf, np.nan))
np.testing.assert_almost_equal(sys_infinite(
0, warn_infinite=False), complex(np.inf, np.nan))


def test_dcgain_consistency():
"""Test to make sure that DC gain is consistently evaluated"""
# Set up transfer function with pole at the origin
sys_tf = ctrl.tf([1], [1, 0])
assert 0 in sys_tf.pole()

# Set up state space system with pole at the origin
sys_ss = ctrl.tf2ss(sys_tf)
assert 0 in sys_ss.pole()

# Finite (real) numerator over 0 denominator => inf + nanj
np.testing.assert_equal(
sys_tf(0, warn_infinite=False), complex(np.inf, np.nan))
np.testing.assert_equal(
sys_ss(0, warn_infinite=False), complex(np.inf, np.nan))
np.testing.assert_equal(
sys_tf(0j, warn_infinite=False), complex(np.inf, np.nan))
np.testing.assert_equal(
sys_ss(0j, warn_infinite=False), complex(np.inf, np.nan))
np.testing.assert_equal(
sys_tf.dcgain(warn_infinite=False), complex(np.inf, np.nan))
np.testing.assert_equal(
sys_ss.dcgain(warn_infinite=False), complex(np.inf, np.nan))

# Set up transfer function with pole, zero at the origin
sys_tf = ctrl.tf([1, 0], [1, 0])
assert 0 in sys_tf.pole()
assert 0 in sys_tf.zero()

# Pole and zero at the origin should give nan + nanj for the response
np.testing.assert_equal(
sys_tf(0, warn_infinite=False), complex(np.nan, np.nan))
np.testing.assert_equal(
sys_tf(0j, warn_infinite=False), complex(np.nan, np.nan))
np.testing.assert_equal(
sys_tf.dcgain(warn_infinite=False), complex(np.nan, np.nan))

# Set up state space version
sys_ss = ctrl.tf2ss(ctrl.tf([1, 0], [1, 1])) * \
ctrl.tf2ss(ctrl.tf([1], [1, 0]))

# Different systems give different representations => test accordingly
if 0 in sys_ss.pole() and 0 in sys_ss.zero():
# Pole and zero at the origin => should get (nan + nanj)
np.testing.assert_equal(
sys_ss(0, warn_infinite=False), complex(np.nan, np.nan))
np.testing.assert_equal(
sys_ss(0j, warn_infinite=False), complex(np.nan, np.nan))
np.testing.assert_equal(
sys_ss.dcgain(warn_infinite=False), complex(np.nan, np.nan))
elif 0 in sys_ss.pole():
# Pole at the origin, but zero elsewhere => should get (inf + nanj)
np.testing.assert_equal(
sys_ss(0, warn_infinite=False), complex(np.inf, np.nan))
np.testing.assert_equal(
sys_ss(0j, warn_infinite=False), complex(np.inf, np.nan))
np.testing.assert_equal(
sys_ss.dcgain(warn_infinite=False), complex(np.inf, np.nan))
else:
# Near pole/zero cancellation => nothing sensible to check
pass

# Pole with non-zero, complex numerator => inf + infj
s = ctrl.tf('s')
sys_tf = (s + 1) / (s**2 + 1)
assert 1j in sys_tf.pole()

# Set up state space system with pole on imaginary axis
sys_ss = ctrl.tf2ss(sys_tf)
assert 1j in sys_tf.pole()

# Make sure we get correct response if evaluated at the pole
np.testing.assert_equal(
sys_tf(1j, warn_infinite=False), complex(np.inf, np.inf))

# For state space, numerical errors come into play
resp_ss = sys_ss(1j, warn_infinite=False)
if np.isfinite(resp_ss):
assert abs(resp_ss) > 1e15
else:
if resp_ss != complex(np.inf, np.inf):
pytest.xfail("statesp evaluation at poles not fully implemented")
else:
np.testing.assert_equal(resp_ss, complex(np.inf, np.inf))

# DC gain is finite
np.testing.assert_almost_equal(sys_tf.dcgain(), 1.)
np.testing.assert_almost_equal(sys_ss.dcgain(), 1.)

# Make sure that we get the *signed* DC gain
sys_tf = -1 / (s + 1)
np.testing.assert_almost_equal(sys_tf.dcgain(), -1)

sys_ss = ctrl.tf2ss(sys_tf)
np.testing.assert_almost_equal(sys_ss.dcgain(), -1)
10 changes: 5 additions & 5 deletions control/tests/input_element_int_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,15 +23,15 @@ def test_tf_den_with_numpy_int_element(self):

sys = tf(num, den)

np.testing.assert_array_max_ulp(1., dcgain(sys))
np.testing.assert_almost_equal(1., dcgain(sys))

def test_tf_num_with_numpy_int_element(self):
num = np.convolve([1], [1, 1])
den = np.convolve([1, 2, 1], [1, 1, 1])

sys = tf(num, den)

np.testing.assert_array_max_ulp(1., dcgain(sys))
np.testing.assert_almost_equal(1., dcgain(sys))

# currently these pass
def test_tf_input_with_int_element(self):
Expand All @@ -40,7 +40,7 @@ def test_tf_input_with_int_element(self):

sys = tf(num, den)

np.testing.assert_array_max_ulp(1., dcgain(sys))
np.testing.assert_almost_equal(1., dcgain(sys))

def test_ss_input_with_int_element(self):
a = np.array([[0, 1],
Expand All @@ -52,7 +52,7 @@ def test_ss_input_with_int_element(self):

sys = ss(a, b, c, d)
sys2 = tf(sys)
np.testing.assert_array_max_ulp(dcgain(sys), dcgain(sys2))
np.testing.assert_almost_equal(dcgain(sys), dcgain(sys2))

def test_ss_input_with_0int_dcgain(self):
a = np.array([[0, 1],
Expand All @@ -63,4 +63,4 @@ def test_ss_input_with_0int_dcgain(self):
d = 0
sys = ss(a, b, c, d)
np.testing.assert_allclose(dcgain(sys), 0,
atol=np.finfo(np.float).epsneg)
atol=np.finfo(np.float).epsneg)
17 changes: 12 additions & 5 deletions control/tests/statesp_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -498,7 +498,7 @@ def test_dc_gain_cont(self):
np.testing.assert_allclose(sys2.dcgain(), expected)

sys3 = StateSpace(0., 1., 1., 0.)
np.testing.assert_equal(sys3.dcgain(), np.nan)
np.testing.assert_equal(sys3.dcgain(), complex(np.inf, np.nan))

def test_dc_gain_discr(self):
"""Test DC gain for discrete-time state-space systems."""
Expand All @@ -516,14 +516,14 @@ def test_dc_gain_discr(self):

# summer
sys = StateSpace(1, 1, 1, 0, True)
np.testing.assert_equal(sys.dcgain(), np.nan)
np.testing.assert_equal(sys.dcgain(), complex(np.inf, np.nan))

@pytest.mark.parametrize("outputs", range(1, 6))
@pytest.mark.parametrize("inputs", range(1, 6))
@pytest.mark.parametrize("dt", [None, 0, 1, True],
ids=["dtNone", "c", "dt1", "dtTrue"])
def test_dc_gain_integrator(self, outputs, inputs, dt):
"""DC gain when eigenvalue at DC returns appropriately sized array of nan.
"""DC gain w/ pole at origin returns appropriately sized array of inf.

the SISO case is also tested in test_dc_gain_{cont,discr}
time systems (dt=0)
Expand All @@ -539,8 +539,15 @@ def test_dc_gain_integrator(self, outputs, inputs, dt):
c = np.eye(max(outputs, states))[:outputs, :states]
d = np.zeros((outputs, inputs))
sys = StateSpace(a, b, c, d, dt)
dc = np.squeeze(np.full_like(d, np.nan))
np.testing.assert_array_equal(dc, sys.dcgain())
dc = np.full_like(d, complex(np.inf, np.nan), dtype=complex)
if sys.issiso():
dc = dc.squeeze()

try:
np.testing.assert_array_equal(dc, sys.dcgain())
except NotImplementedError:
# Skip MIMO tests if there is no slycot
pytest.skip("slycot required for MIMO dcgain")

def test_scalar_static_gain(self):
"""Regression: can we create a scalar static gain?
Expand Down
Loading