Skip to content

Commit 7407265

Browse files
committed
Fixed docstrings per roryyorke plus discrete-time corrections
* changed return type for poles() and zeros() to complex * updated (missing) discrete-time tests for stability warnings * changed processing of near imaginary poles to pure imaginary poles
1 parent 5d9c7f0 commit 7407265

File tree

4 files changed

+62
-30
lines changed

4 files changed

+62
-30
lines changed

control/freqplot.py

+36-21
Original file line numberDiff line numberDiff line change
@@ -644,14 +644,14 @@ def nyquist_plot(
644644
Linestyles for mirror image of the Nyquist curve. The first element
645645
is used for unscaled portions of the Nyquist curve, the second element
646646
is used for portions that are scaled (using max_curve_magnitude). If
647-
`False` then omit completely. Default linestyle (['--', '-.']) is
647+
`False` then omit completely. Default linestyle (['--', ':']) is
648648
determined by config.defaults['nyquist.mirror_style'].
649649
650650
primary_style : [str, str], optional
651651
Linestyles for primary image of the Nyquist curve. The first
652652
element is used for unscaled portions of the Nyquist curve,
653653
the second element is used for portions that are scaled (using
654-
max_curve_magnitude). Default linestyle (['-', ':']) is
654+
max_curve_magnitude). Default linestyle (['-', '-.']) is
655655
determined by config.defaults['nyquist.mirror_style'].
656656
657657
start_marker : str, optional
@@ -750,6 +750,9 @@ def _parse_linestyle(style_name, allow_false=False):
750750
if isinstance(style, str):
751751
# Only one style provided, use the default for the other
752752
style = [style, _nyquist_defaults['nyquist.' + style_name][1]]
753+
warnings.warn(
754+
"use of a single string for linestyle will be deprecated "
755+
" in a future release", PendingDeprecationWarning)
753756
if (allow_false and style is False) or \
754757
(isinstance(style, list) and len(style) == 2):
755758
return style
@@ -765,7 +768,7 @@ def _parse_linestyle(style_name, allow_false=False):
765768

766769
# Determine the range of frequencies to use, based on args/features
767770
omega, omega_range_given = _determine_omega_vector(
768-
syslist, omega, omega_limits, omega_num)
771+
syslist, omega, omega_limits, omega_num, feature_periphery_decades=2)
769772

770773
# If omega was not specified explicitly, start at omega = 0
771774
if not omega_range_given:
@@ -790,7 +793,7 @@ def _parse_linestyle(style_name, allow_false=False):
790793

791794
# Determine the contour used to evaluate the Nyquist curve
792795
if sys.isdtime(strict=True):
793-
# Transform frequencies in for discrete-time systems
796+
# Restrict frequencies for discrete-time systems
794797
nyquistfrq = math.pi / sys.dt
795798
if not omega_range_given:
796799
# limit up to and including nyquist frequency
@@ -817,12 +820,12 @@ def _parse_linestyle(style_name, allow_false=False):
817820
# because we don't need to indent for them
818821
zplane_poles = sys.poles()
819822
zplane_poles = zplane_poles[~np.isclose(abs(zplane_poles), 0.)]
820-
splane_poles = np.log(zplane_poles)/sys.dt
823+
splane_poles = np.log(zplane_poles) / sys.dt
821824

822825
zplane_cl_poles = sys.feedback().poles()
823826
zplane_cl_poles = zplane_cl_poles[
824827
~np.isclose(abs(zplane_poles), 0.)]
825-
splane_cl_poles = np.log(zplane_cl_poles)/sys.dt
828+
splane_cl_poles = np.log(zplane_cl_poles) / sys.dt
826829

827830
#
828831
# Check to make sure indent radius is small enough
@@ -851,8 +854,8 @@ def _parse_linestyle(style_name, allow_false=False):
851854
# See if we should add some frequency points near imaginary poles
852855
#
853856
for p in splane_poles:
854-
# See if we need to process this pole (skip any that is on
855-
# the not near or on the negative omega axis + user override)
857+
# See if we need to process this pole (skip if on the negative
858+
# imaginary axis or not near imaginary axis + user override)
856859
if p.imag < 0 or abs(p.real) > indent_radius or \
857860
omega_range_given:
858861
continue
@@ -894,13 +897,13 @@ def _parse_linestyle(style_name, allow_false=False):
894897
- (s - p).real
895898

896899
# Figure out which way to offset the contour point
897-
if p.real < 0 or (np.isclose(p.real, 0)
898-
and indent_direction == 'right'):
900+
if p.real < 0 or (p.real == 0 and
901+
indent_direction == 'right'):
899902
# Indent to the right
900903
splane_contour[i] += offset
901904

902-
elif p.real > 0 or (np.isclose(p.real, 0)
903-
and indent_direction == 'left'):
905+
elif p.real > 0 or (p.real == 0 and
906+
indent_direction == 'left'):
904907
# Indent to the left
905908
splane_contour[i] -= offset
906909

@@ -937,9 +940,21 @@ def _parse_linestyle(style_name, allow_false=False):
937940
# Nyquist criterion is actually satisfied.
938941
#
939942
if isinstance(sys, (StateSpace, TransferFunction)):
940-
P = (sys.poles().real > 0).sum() if indent_direction == 'right' \
941-
else (sys.poles().real >= 0).sum()
942-
Z = (sys.feedback().poles().real >= 0).sum()
943+
# Count the number of open/closed loop RHP poles
944+
if sys.isctime():
945+
if indent_direction == 'right':
946+
P = (sys.poles().real > 0).sum()
947+
else:
948+
P = (sys.poles().real >= 0).sum()
949+
Z = (sys.feedback().poles().real >= 0).sum()
950+
else:
951+
if indent_direction == 'right':
952+
P = (np.abs(sys.poles()) > 1).sum()
953+
else:
954+
P = (np.abs(sys.poles()) >= 1).sum()
955+
Z = (np.abs(sys.feedback().poles()) >= 1).sum()
956+
957+
# Check to make sure the results make sense; warn if not
943958
if Z != count + P and warn_encirclements:
944959
warnings.warn(
945960
"number of encirclements does not match Nyquist criterion;"
@@ -976,7 +991,7 @@ def _parse_linestyle(style_name, allow_false=False):
976991
# Find the different portions of the curve (with scaled pts marked)
977992
reg_mask = np.logical_or(
978993
np.abs(resp) > max_curve_magnitude,
979-
contour.real != 0)
994+
splane_contour.real != 0)
980995
# reg_mask = np.logical_or(
981996
# np.abs(resp.real) > max_curve_magnitude,
982997
# np.abs(resp.imag) > max_curve_magnitude)
@@ -1508,7 +1523,7 @@ def singular_values_plot(syslist, omega=None,
15081523

15091524
# Determine the frequency range to be used
15101525
def _determine_omega_vector(syslist, omega_in, omega_limits, omega_num,
1511-
Hz=None):
1526+
Hz=None, feature_periphery_decades=None):
15121527
"""Determine the frequency range for a frequency-domain plot
15131528
according to a standard logic.
15141529
@@ -1554,9 +1569,9 @@ def _determine_omega_vector(syslist, omega_in, omega_limits, omega_num,
15541569
if omega_limits is None:
15551570
omega_range_given = False
15561571
# Select a default range if none is provided
1557-
omega_out = _default_frequency_range(syslist,
1558-
number_of_samples=omega_num,
1559-
Hz=Hz)
1572+
omega_out = _default_frequency_range(
1573+
syslist, number_of_samples=omega_num, Hz=Hz,
1574+
feature_periphery_decades=feature_periphery_decades)
15601575
else:
15611576
omega_limits = np.asarray(omega_limits)
15621577
if len(omega_limits) != 2:
@@ -1640,7 +1655,7 @@ def _default_frequency_range(syslist, Hz=None, number_of_samples=None,
16401655

16411656
features_ = np.concatenate((sys.poles(), sys.zeros()))
16421657
# Get rid of poles and zeros on the real axis (imag==0)
1643-
# * origin and real < 0
1658+
# * origin and real < 0
16441659
# * at 1.: would result in omega=0. (logaritmic plot!)
16451660
toreplace = np.isclose(features_.imag, 0.0) & (
16461661
(features_.real <= 0.) |

control/statesp.py

+5-3
Original file line numberDiff line numberDiff line change
@@ -987,7 +987,8 @@ def freqresp(self, omega):
987987
def poles(self):
988988
"""Compute the poles of a state space system."""
989989

990-
return eigvals(self.A) if self.nstates else np.array([])
990+
return eigvals(self.A).astype(complex) if self.nstates \
991+
else np.array([])
991992

992993
def zeros(self):
993994
"""Compute the zeros of a state space system."""
@@ -1006,8 +1007,9 @@ def zeros(self):
10061007
if nu == 0:
10071008
return np.array([])
10081009
else:
1010+
# Use SciPy generalized eigenvalue fucntion
10091011
return sp.linalg.eigvals(out[8][0:nu, 0:nu],
1010-
out[9][0:nu, 0:nu])
1012+
out[9][0:nu, 0:nu]).astype(complex)
10111013

10121014
except ImportError: # Slycot unavailable. Fall back to scipy.
10131015
if self.C.shape[0] != self.D.shape[1]:
@@ -1031,7 +1033,7 @@ def zeros(self):
10311033
(0, self.B.shape[1])), "constant")
10321034
return np.array([x for x in sp.linalg.eigvals(L, M,
10331035
overwrite_a=True)
1034-
if not isinf(x)])
1036+
if not isinf(x)], dtype=complex)
10351037

10361038
# Feedback around a state space system
10371039
def feedback(self, other=1, sign=-1):

control/tests/nyquist_test.py

+19-4
Original file line numberDiff line numberDiff line change
@@ -350,7 +350,8 @@ def test_linestyle_checks():
350350

351351
# If only one line style is given use, the default value for the other
352352
# TODO: for now, just make sure the signature works; no correct check yet
353-
ct.nyquist_plot(sys, primary_style=':', mirror_style='-.')
353+
with pytest.warns(PendingDeprecationWarning, match="single string"):
354+
ct.nyquist_plot(sys, primary_style=':', mirror_style='-.')
354355

355356
@pytest.mark.usefixtures("editsdefaults")
356357
def test_nyquist_legacy():
@@ -363,13 +364,17 @@ def test_nyquist_legacy():
363364
with pytest.warns(UserWarning, match="indented contour may miss"):
364365
count = ct.nyquist_plot(sys)
365366

366-
367+
def test_discrete_nyquist():
368+
# Make sure we can handle discrete time systems with negative poles
369+
sys = ct.tf(1, [1, -0.1], dt=1) * ct.tf(1, [1, 0.1], dt=1)
370+
ct.nyquist_plot(sys)
371+
367372
if __name__ == "__main__":
368373
#
369374
# Interactive mode: generate plots for manual viewing
370375
#
371-
# Running this script in python (or better ipython) will show a collection of
372-
# figures that should all look OK on the screeen.
376+
# Running this script in python (or better ipython) will show a
377+
# collection of figures that should all look OK on the screeen.
373378
#
374379

375380
# In interactive mode, turn on ipython interactive graphics
@@ -411,3 +416,13 @@ def test_nyquist_legacy():
411416
np.array2string(sys.poles(), precision=2, separator=','))
412417
count = ct.nyquist_plot(sys)
413418
assert _Z(sys) == count + _P(sys)
419+
420+
print("Discrete time systems")
421+
sys = ct.c2d(sys, 0.01)
422+
plt.figure()
423+
plt.title("Discrete-time; poles: %s" %
424+
np.array2string(sys.poles(), precision=2, separator=','))
425+
count = ct.nyquist_plot(sys)
426+
427+
428+

control/xferfcn.py

+2-2
Original file line numberDiff line numberDiff line change
@@ -798,7 +798,7 @@ def poles(self):
798798
rts = []
799799
for d, o in zip(den, denorder):
800800
rts.extend(roots(d[:o + 1]))
801-
return np.array(rts)
801+
return np.array(rts).astype(complex)
802802

803803
def zeros(self):
804804
"""Compute the zeros of a transfer function."""
@@ -808,7 +808,7 @@ def zeros(self):
808808
"for SISO systems.")
809809
else:
810810
# for now, just give zeros of a SISO tf
811-
return roots(self.num[0][0])
811+
return roots(self.num[0][0]).astype(complex)
812812

813813
def feedback(self, other=1, sign=-1):
814814
"""Feedback interconnection between two LTI objects."""

0 commit comments

Comments
 (0)