From bab9f7bebbc279b9e484e146f23c436d7c0c7354 Mon Sep 17 00:00:00 2001 From: Sawyer Fuller Date: Thu, 28 Jan 2021 08:28:06 -0800 Subject: [PATCH 01/21] removed backspace character in margins plot title because it shows as an empty glyph (on mac) --- control/freqplot.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/control/freqplot.py b/control/freqplot.py index ef4263bbe..1a3f6402a 100644 --- a/control/freqplot.py +++ b/control/freqplot.py @@ -457,7 +457,7 @@ def bode_plot(syslist, omega=None, "Gm = %.2f %s(at %.2f %s), " "Pm = %.2f %s (at %.2f %s)" % (20*np.log10(gm) if dB else gm, - 'dB ' if dB else '\b', + 'dB ' if dB else '', Wcg, 'Hz' if Hz else 'rad/s', pm if deg else math.radians(pm), 'deg' if deg else 'rad', From 72c5e069fb21b26f8a366f71df49103c94d369c9 Mon Sep 17 00:00:00 2001 From: Sawyer Fuller Date: Thu, 28 Jan 2021 08:50:05 -0800 Subject: [PATCH 02/21] evalfr(sys,s) -> sys(s); mimo errors specified as ControlMIMONotImplemented in a few places --- control/freqplot.py | 13 +++++++------ control/margins.py | 14 +++++++------- 2 files changed, 14 insertions(+), 13 deletions(-) diff --git a/control/freqplot.py b/control/freqplot.py index 1a3f6402a..159c1c4cd 100644 --- a/control/freqplot.py +++ b/control/freqplot.py @@ -50,6 +50,7 @@ from .ctrlutil import unwrap from .bdalg import feedback from .margins import stability_margins +from .exception import ControlMIMONotImplemented from . import config __all__ = ['bode_plot', 'nyquist_plot', 'gangof4_plot', @@ -214,9 +215,9 @@ def bode_plot(syslist, omega=None, mags, phases, omegas, nyquistfrqs = [], [], [], [] for sys in syslist: - if sys.ninputs > 1 or sys.noutputs > 1: + if not sys.issiso(): # TODO: Add MIMO bode plots. - raise NotImplementedError( + raise ControlMIMONotImplemented( "Bode is currently only implemented for SISO systems.") else: omega_sys = np.array(omega) @@ -582,9 +583,9 @@ def nyquist_plot(syslist, omega=None, plot=True, label_freq=0, num=50, endpoint=True, base=10.0) for sys in syslist: - if sys.ninputs > 1 or sys.noutputs > 1: + if not sys.issiso(): # TODO: Add MIMO nyquist plots. - raise NotImplementedError( + raise ControlMIMONotImplemented( "Nyquist is currently only implemented for SISO systems.") else: # Get the magnitude and phase of the system @@ -672,9 +673,9 @@ def gangof4_plot(P, C, omega=None, **kwargs): ------- None """ - if P.ninputs > 1 or P.noutputs > 1 or C.ninputs > 1 or C.noutputs > 1: + if not P.issiso() or not C.issiso(): # TODO: Add MIMO go4 plots. - raise NotImplementedError( + raise ControlMIMONotImplemented( "Gang of four is currently only implemented for SISO systems.") # Get the default parameter values diff --git a/control/margins.py b/control/margins.py index 20da2a879..af7c63c56 100644 --- a/control/margins.py +++ b/control/margins.py @@ -207,7 +207,7 @@ def fun(wdt): # Took the framework for the old function by -# Sawyer B. Fuller , removed a lot of the innards +# Sawyer B. Fuller , removed a lot of the innards # and replaced with analytical polynomial functions for LTI systems. # # idea for the frequency data solution copied/adapted from @@ -294,29 +294,29 @@ def stability_margins(sysdata, returnall=False, epsw=0.0): # 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 = evalfr(sys, 1J * w_180) + w180_resp = sys(1J * w_180) # frequency for phase margin : gain crosses magnitude 1 wc = _poly_iw_mag1_crossing(num_iw, den_iw, epsw) - wc_resp = evalfr(sys, 1J * wc) + wc_resp = sys(1J * wc) # stability margin wstab = _poly_iw_wstab(num_iw, den_iw, epsw) - ws_resp = evalfr(sys, 1J * wstab) + ws_resp = sys(1J * wstab) else: # Discrete Time zargs = _poly_z_invz(sys) # gain margin z, w_180 = _poly_z_real_crossing(*zargs, epsw=epsw) - w180_resp = evalfr(sys, z) + w180_resp = sys(z) # phase margin z, wc = _poly_z_mag1_crossing(*zargs, epsw=epsw) - wc_resp = evalfr(sys, z) + wc_resp = sys(z) # stability margin z, wstab = _poly_z_wstab(*zargs, epsw=epsw) - ws_resp = evalfr(sys, z) + ws_resp = sys(z) # only keep frequencies where the negative real axis is crossed w_180 = w_180[w180_resp <= 0.] From ddaeb6db1b1c060b285425b81d3205f95f389610 Mon Sep 17 00:00:00 2001 From: Sawyer Fuller Date: Thu, 28 Jan 2021 09:37:15 -0800 Subject: [PATCH 03/21] freqplot: use reasonable number of frequency points rather than default of 50 for logspace; unify frequency range specification for bode and nyquist --- control/freqplot.py | 173 ++++++++++++++++++++++++-------------------- 1 file changed, 95 insertions(+), 78 deletions(-) diff --git a/control/freqplot.py b/control/freqplot.py index 159c1c4cd..c9d9d2899 100644 --- a/control/freqplot.py +++ b/control/freqplot.py @@ -59,7 +59,7 @@ # Default values for module parameter variables _freqplot_defaults = { 'freqplot.feature_periphery_decades': 1, - 'freqplot.number_of_samples': None, + 'freqplot.number_of_samples': 1000, } # @@ -94,7 +94,7 @@ def bode_plot(syslist, omega=None, ---------- syslist : linsys List of linear input/output systems (single system is OK) - omega : list + omega : array_like List of frequencies in rad/sec to be used for frequency response dB : bool If True, plot result in dB. Default is false. @@ -106,10 +106,10 @@ def bode_plot(syslist, omega=None, config.defaults['bode.deg'] plot : bool If True (default), plot magnitude and phase - omega_limits: tuple, list, ... of two values + omega_limits : array_like of two values Limits of the to generate frequency vector. If Hz=True the limits are in Hz otherwise in rad/s. - omega_num: int + omega_num : int Number of samples to plot. Defaults to config.defaults['freqplot.number_of_samples']. margins : bool @@ -200,18 +200,18 @@ def bode_plot(syslist, omega=None, omega = default_frequency_range(syslist, Hz=Hz, number_of_samples=omega_num) else: - omega_limits = np.array(omega_limits) + omega_limits = np.asarray(omega_limits) + if len(omega_limits) != 2: + raise ValueError("len(omega_limits) must be 2") if Hz: omega_limits *= 2. * math.pi if omega_num: - omega = np.logspace(np.log10(omega_limits[0]), - np.log10(omega_limits[1]), - num=omega_num, - endpoint=True) + num = omega_num else: - omega = np.logspace(np.log10(omega_limits[0]), - np.log10(omega_limits[1]), - endpoint=True) + num = config.defaults['freqplot.number_of_samples'] + omega = np.logspace(np.log10(omega_limits[0]), + np.log10(omega_limits[1]), num=num, + endpoint=True) mags, phases, omegas, nyquistfrqs = [], [], [], [] for sys in syslist: @@ -507,9 +507,9 @@ def gen_zero_centered_series(val_min, val_max, period): # Nyquist plot # -def nyquist_plot(syslist, omega=None, plot=True, label_freq=0, - arrowhead_length=0.1, arrowhead_width=0.1, - color=None, *args, **kwargs): +def nyquist_plot(syslist, omega=None, plot=True, omega_limits=None, + omega_num=None, label_freq=0, arrowhead_length=0.1, + arrowhead_width=0.1, color=None, *args, **kwargs): """ Nyquist plot for a system @@ -519,16 +519,23 @@ def nyquist_plot(syslist, omega=None, plot=True, label_freq=0, ---------- syslist : list of LTI List of linear input/output systems (single system is OK) - omega : freq_range - Range of frequencies (list or bounds) in rad/sec - Plot : boolean + plot : boolean If True, plot magnitude + omega : array_like + Range of frequencies in rad/sec + omega_limits : array_like of two values + Limits of the to generate frequency vector. + omega_num : int + Number of samples to plot. Defaults to + config.defaults['freqplot.number_of_samples']. color : string - Used to specify the color of the plot + Used to specify the color of the line and arrowhead label_freq : int Label every nth frequency on the plot - arrowhead_width : arrow head width - arrowhead_length : arrow head length + arrowhead_width : float + Arrow head width + arrowhead_length : float + Arrow head length *args : :func:`matplotlib.pyplot.plot` positional properties, optional Additional arguments for `matplotlib` plots (color, linestyle, etc) **kwargs : :func:`matplotlib.pyplot.plot` keyword properties, optional @@ -536,12 +543,12 @@ def nyquist_plot(syslist, omega=None, plot=True, label_freq=0, Returns ------- - real : array + real : ndarray real part of the frequency response array - imag : array + imag : ndarray imaginary part of the frequency response array - freq : array - frequencies + freq : ndarray + frequencies in rad/s Examples -------- @@ -571,7 +578,21 @@ def nyquist_plot(syslist, omega=None, plot=True, label_freq=0, # Select a default range if none is provided if omega is None: - omega = default_frequency_range(syslist) + if omega_limits is None: + # Select a default range if none is provided + omega = default_frequency_range(syslist, Hz=False, + number_of_samples=omega_num) + else: + omega_limits = np.asarray(omega_limits) + if len(omega_limits) != 2: + raise ValueError("len(omega_limits) must be 2") + if omega_num: + num = omega_num + else: + num = config.defaults['freqplot.number_of_samples'] + omega = np.logspace(np.log10(omega_limits[0]), + np.log10(omega_limits[1]), num=num, + endpoint=True) # Interpolate between wmin and wmax if a tuple or list are provided elif isinstance(omega, list) or isinstance(omega, tuple): @@ -580,65 +601,61 @@ def nyquist_plot(syslist, omega=None, plot=True, label_freq=0, raise ValueError("Supported frequency arguments are (wmin,wmax)" "tuple or list, or frequency vector. ") omega = np.logspace(np.log10(omega[0]), np.log10(omega[1]), - num=50, endpoint=True, base=10.0) + num=500, endpoint=True, base=10.0) for sys in syslist: if not sys.issiso(): # TODO: Add MIMO nyquist plots. raise ControlMIMONotImplemented( "Nyquist is currently only implemented for SISO systems.") - else: - # Get the magnitude and phase of the system - mag_tmp, phase_tmp, omega = sys.frequency_response(omega) - mag = np.squeeze(mag_tmp) - phase = np.squeeze(phase_tmp) - - # Compute the primary curve - x = np.multiply(mag, np.cos(phase)) - y = np.multiply(mag, np.sin(phase)) - if plot: - # Plot the primary curve and mirror image - p = plt.plot(x, y, '-', color=color, *args, **kwargs) - c = p[0].get_color() - ax = plt.gca() - # Plot arrow to indicate Nyquist encirclement orientation - ax.arrow(x[0], y[0], (x[1]-x[0])/2, (y[1]-y[0])/2, fc=c, ec=c, - head_width=arrowhead_width, - head_length=arrowhead_length) - - plt.plot(x, -y, '-', color=c, *args, **kwargs) - ax.arrow( - x[-1], -y[-1], (x[-1]-x[-2])/2, (y[-1]-y[-2])/2, - fc=c, ec=c, head_width=arrowhead_width, - head_length=arrowhead_length) - - # Mark the -1 point - plt.plot([-1], [0], 'r+') - - # Label the frequencies of the points - if label_freq: - ind = slice(None, None, label_freq) - for xpt, ypt, omegapt in zip(x[ind], y[ind], omega[ind]): - # Convert to Hz - f = omegapt / (2 * np.pi) - - # Factor out multiples of 1000 and limit the - # result to the range [-8, 8]. - pow1000 = max(min(get_pow1000(f), 8), -8) - - # Get the SI prefix. - prefix = gen_prefix(pow1000) - - # Apply the text. (Use a space before the text to - # prevent overlap with the data.) - # - # np.round() is used because 0.99... appears - # instead of 1.0, and this would otherwise be - # truncated to 0. - plt.text(xpt, ypt, ' ' + - str(int(np.round(f / 1000 ** pow1000, 0))) + ' ' + - prefix + 'Hz') + # Get the magnitude and phase of the system + mag, phase, omega = sys.frequency_response(omega) + + # Compute the primary curve + x = mag * np.cos(phase) + y = mag * np.sin(phase) + + if plot: + # Plot the primary curve and mirror image + p = plt.plot(np.hstack((x,x)), np.hstack((y,-y)), + '-', color=color, *args, **kwargs) + c = p[0].get_color() + ax = plt.gca() + # Plot arrow to indicate Nyquist encirclement orientation + ax.arrow(x[0], y[0], (x[1]-x[0])/2, (y[1]-y[0])/2, + fc=c, ec=c, head_width=arrowhead_width, + head_length=arrowhead_length, label=None) + ax.arrow(x[-1], -y[-1], (x[-1]-x[-2])/2, (y[-1]-y[-2])/2, + fc=c, ec=c, head_width=arrowhead_width, + head_length=arrowhead_length, label=None) + + # Mark the -1 point + plt.plot([-1], [0], 'r+') + + # Label the frequencies of the points + if label_freq: + ind = slice(None, None, label_freq) + for xpt, ypt, omegapt in zip(x[ind], y[ind], omega[ind]): + # Convert to Hz + f = omegapt / (2 * np.pi) + + # Factor out multiples of 1000 and limit the + # result to the range [-8, 8]. + pow1000 = max(min(get_pow1000(f), 8), -8) + + # Get the SI prefix. + prefix = gen_prefix(pow1000) + + # Apply the text. (Use a space before the text to + # prevent overlap with the data.) + # + # np.round() is used because 0.99... appears + # instead of 1.0, and this would otherwise be + # truncated to 0. + plt.text(xpt, ypt, ' ' + + str(int(np.round(f / 1000 ** pow1000, 0))) + ' ' + + prefix + 'Hz') if plot: ax = plt.gca() From 70c9855dd43dd74b0f4596b5f8a3f69148ddb2e2 Mon Sep 17 00:00:00 2001 From: Sawyer Fuller Date: Thu, 28 Jan 2021 10:11:02 -0800 Subject: [PATCH 04/21] convert first system passed to append into ss if necessary --- control/bdalg.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/control/bdalg.py b/control/bdalg.py index 20c9f4b09..10d49f130 100644 --- a/control/bdalg.py +++ b/control/bdalg.py @@ -54,6 +54,7 @@ """ import numpy as np +from scipy.signal.ltisys import StateSpace from . import xferfcn as tf from . import statesp as ss from . import frdata as frd @@ -280,7 +281,10 @@ def append(*sys): >>> sys = append(sys1, sys2) """ - s1 = sys[0] + if not isinstance(sys[0], StateSpace): + s1 = ss._convert_to_statespace(sys[0]) + else: + s1 = sys[0] for s in sys[1:]: s1 = s1.append(s) return s1 From 65171d3cda41b358b6ef6efa2a489431519df63f Mon Sep 17 00:00:00 2001 From: Sawyer Fuller Date: Thu, 28 Jan 2021 13:23:01 -0800 Subject: [PATCH 05/21] a few small code cleanups --- control/freqplot.py | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/control/freqplot.py b/control/freqplot.py index c9d9d2899..2e476483e 100644 --- a/control/freqplot.py +++ b/control/freqplot.py @@ -220,18 +220,17 @@ def bode_plot(syslist, omega=None, raise ControlMIMONotImplemented( "Bode is currently only implemented for SISO systems.") else: - omega_sys = np.array(omega) - if sys.isdtime(True): + omega_sys = np.asarray(omega) + if sys.isdtime(strict=True): nyquistfrq = 2. * math.pi * 1. / sys.dt / 2. omega_sys = omega_sys[omega_sys < nyquistfrq] # TODO: What distance to the Nyquist frequency is appropriate? else: nyquistfrq = None - # Get the magnitude and phase of the system - mag_tmp, phase_tmp, omega_sys = sys.frequency_response(omega_sys) - mag = np.atleast_1d(np.squeeze(mag_tmp)) - phase = np.atleast_1d(np.squeeze(phase_tmp)) + mag, phase, omega_sys = sys.frequency_response(omega_sys) + mag = np.atleast_1d(mag) + phase = np.atleast_1d(phase) # # Post-process the phase to handle initial value and wrapping @@ -352,8 +351,7 @@ def bode_plot(syslist, omega=None, # Show the phase and gain margins in the plot if margins: # Compute stability margins for the system - margin = stability_margins(sys) - gm, pm, Wcg, Wcp = (margin[i] for i in (0, 1, 3, 4)) + gm, pm, Wcg, Wcp = stability_margins(sys)[0:4] # Figure out sign of the phase at the first gain crossing # (needed if phase_wrap is True) From 060b2f0793e9955c679195330f2768615b8ed7bf Mon Sep 17 00:00:00 2001 From: Sawyer Fuller Date: Thu, 28 Jan 2021 13:55:06 -0800 Subject: [PATCH 06/21] fixes to pass tests --- control/freqplot.py | 3 ++- control/tests/config_test.py | 2 +- control/tests/sisotool_test.py | 4 ++-- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/control/freqplot.py b/control/freqplot.py index 2e476483e..8a4e41d30 100644 --- a/control/freqplot.py +++ b/control/freqplot.py @@ -351,7 +351,8 @@ def bode_plot(syslist, omega=None, # Show the phase and gain margins in the plot if margins: # Compute stability margins for the system - gm, pm, Wcg, Wcp = stability_margins(sys)[0:4] + margin = stability_margins(sys) + gm, pm, Wcg, Wcp = (margin[i] for i in (0, 1, 3, 4)) # Figure out sign of the phase at the first gain crossing # (needed if phase_wrap is True) diff --git a/control/tests/config_test.py b/control/tests/config_test.py index 02d0ad51c..b64240064 100644 --- a/control/tests/config_test.py +++ b/control/tests/config_test.py @@ -203,7 +203,7 @@ def test_reset_defaults(self): assert not ct.config.defaults['bode.dB'] assert ct.config.defaults['bode.deg'] assert not ct.config.defaults['bode.Hz'] - assert ct.config.defaults['freqplot.number_of_samples'] is None + assert ct.config.defaults['freqplot.number_of_samples'] is 1000 assert ct.config.defaults['freqplot.feature_periphery_decades'] == 1.0 def test_legacy_defaults(self): diff --git a/control/tests/sisotool_test.py b/control/tests/sisotool_test.py index 65f87f28b..09c73179f 100644 --- a/control/tests/sisotool_test.py +++ b/control/tests/sisotool_test.py @@ -78,8 +78,8 @@ def test_sisotool(self, sys): # Check if the bode_mag line has moved bode_mag_moved = np.array( - [111.83321224, 92.29238035, 76.02822315, 62.46884113, 51.14108703, - 41.6554004, 33.69409534, 27.00237344, 21.38086717, 16.67791585]) + [674.0242, 667.8354, 661.7033, 655.6275, 649.6074, 643.6426, + 637.7324, 631.8765, 626.0742, 620.3252]) assert_array_almost_equal(ax_mag.lines[0].get_data()[1][10:20], bode_mag_moved, 4) From 151fb6c99ff8ea6b89d808d307d2654eff01cdef Mon Sep 17 00:00:00 2001 From: Sawyer Fuller Date: Thu, 28 Jan 2021 08:28:06 -0800 Subject: [PATCH 07/21] removed backspace character in margins plot title because it shows as an empty glyph (on mac) --- control/freqplot.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/control/freqplot.py b/control/freqplot.py index ef4263bbe..1a3f6402a 100644 --- a/control/freqplot.py +++ b/control/freqplot.py @@ -457,7 +457,7 @@ def bode_plot(syslist, omega=None, "Gm = %.2f %s(at %.2f %s), " "Pm = %.2f %s (at %.2f %s)" % (20*np.log10(gm) if dB else gm, - 'dB ' if dB else '\b', + 'dB ' if dB else '', Wcg, 'Hz' if Hz else 'rad/s', pm if deg else math.radians(pm), 'deg' if deg else 'rad', From ee6a72e638ef738e17aeb87d7595be0b7e87d2fc Mon Sep 17 00:00:00 2001 From: Sawyer Fuller Date: Thu, 28 Jan 2021 08:50:05 -0800 Subject: [PATCH 08/21] evalfr(sys,s) -> sys(s); mimo errors specified as ControlMIMONotImplemented in a few places --- control/freqplot.py | 13 +++++++------ control/margins.py | 14 +++++++------- 2 files changed, 14 insertions(+), 13 deletions(-) diff --git a/control/freqplot.py b/control/freqplot.py index 1a3f6402a..159c1c4cd 100644 --- a/control/freqplot.py +++ b/control/freqplot.py @@ -50,6 +50,7 @@ from .ctrlutil import unwrap from .bdalg import feedback from .margins import stability_margins +from .exception import ControlMIMONotImplemented from . import config __all__ = ['bode_plot', 'nyquist_plot', 'gangof4_plot', @@ -214,9 +215,9 @@ def bode_plot(syslist, omega=None, mags, phases, omegas, nyquistfrqs = [], [], [], [] for sys in syslist: - if sys.ninputs > 1 or sys.noutputs > 1: + if not sys.issiso(): # TODO: Add MIMO bode plots. - raise NotImplementedError( + raise ControlMIMONotImplemented( "Bode is currently only implemented for SISO systems.") else: omega_sys = np.array(omega) @@ -582,9 +583,9 @@ def nyquist_plot(syslist, omega=None, plot=True, label_freq=0, num=50, endpoint=True, base=10.0) for sys in syslist: - if sys.ninputs > 1 or sys.noutputs > 1: + if not sys.issiso(): # TODO: Add MIMO nyquist plots. - raise NotImplementedError( + raise ControlMIMONotImplemented( "Nyquist is currently only implemented for SISO systems.") else: # Get the magnitude and phase of the system @@ -672,9 +673,9 @@ def gangof4_plot(P, C, omega=None, **kwargs): ------- None """ - if P.ninputs > 1 or P.noutputs > 1 or C.ninputs > 1 or C.noutputs > 1: + if not P.issiso() or not C.issiso(): # TODO: Add MIMO go4 plots. - raise NotImplementedError( + raise ControlMIMONotImplemented( "Gang of four is currently only implemented for SISO systems.") # Get the default parameter values diff --git a/control/margins.py b/control/margins.py index 20da2a879..af7c63c56 100644 --- a/control/margins.py +++ b/control/margins.py @@ -207,7 +207,7 @@ def fun(wdt): # Took the framework for the old function by -# Sawyer B. Fuller , removed a lot of the innards +# Sawyer B. Fuller , removed a lot of the innards # and replaced with analytical polynomial functions for LTI systems. # # idea for the frequency data solution copied/adapted from @@ -294,29 +294,29 @@ def stability_margins(sysdata, returnall=False, epsw=0.0): # 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 = evalfr(sys, 1J * w_180) + w180_resp = sys(1J * w_180) # frequency for phase margin : gain crosses magnitude 1 wc = _poly_iw_mag1_crossing(num_iw, den_iw, epsw) - wc_resp = evalfr(sys, 1J * wc) + wc_resp = sys(1J * wc) # stability margin wstab = _poly_iw_wstab(num_iw, den_iw, epsw) - ws_resp = evalfr(sys, 1J * wstab) + ws_resp = sys(1J * wstab) else: # Discrete Time zargs = _poly_z_invz(sys) # gain margin z, w_180 = _poly_z_real_crossing(*zargs, epsw=epsw) - w180_resp = evalfr(sys, z) + w180_resp = sys(z) # phase margin z, wc = _poly_z_mag1_crossing(*zargs, epsw=epsw) - wc_resp = evalfr(sys, z) + wc_resp = sys(z) # stability margin z, wstab = _poly_z_wstab(*zargs, epsw=epsw) - ws_resp = evalfr(sys, z) + ws_resp = sys(z) # only keep frequencies where the negative real axis is crossed w_180 = w_180[w180_resp <= 0.] From 1c0764deaae0e6966ac6c8ddabf9457c84421e5d Mon Sep 17 00:00:00 2001 From: Sawyer Fuller Date: Thu, 28 Jan 2021 09:37:15 -0800 Subject: [PATCH 09/21] freqplot: use reasonable number of frequency points rather than default of 50 for logspace; unify frequency range specification for bode and nyquist --- control/freqplot.py | 173 ++++++++++++++++++++++++-------------------- 1 file changed, 95 insertions(+), 78 deletions(-) diff --git a/control/freqplot.py b/control/freqplot.py index 159c1c4cd..c9d9d2899 100644 --- a/control/freqplot.py +++ b/control/freqplot.py @@ -59,7 +59,7 @@ # Default values for module parameter variables _freqplot_defaults = { 'freqplot.feature_periphery_decades': 1, - 'freqplot.number_of_samples': None, + 'freqplot.number_of_samples': 1000, } # @@ -94,7 +94,7 @@ def bode_plot(syslist, omega=None, ---------- syslist : linsys List of linear input/output systems (single system is OK) - omega : list + omega : array_like List of frequencies in rad/sec to be used for frequency response dB : bool If True, plot result in dB. Default is false. @@ -106,10 +106,10 @@ def bode_plot(syslist, omega=None, config.defaults['bode.deg'] plot : bool If True (default), plot magnitude and phase - omega_limits: tuple, list, ... of two values + omega_limits : array_like of two values Limits of the to generate frequency vector. If Hz=True the limits are in Hz otherwise in rad/s. - omega_num: int + omega_num : int Number of samples to plot. Defaults to config.defaults['freqplot.number_of_samples']. margins : bool @@ -200,18 +200,18 @@ def bode_plot(syslist, omega=None, omega = default_frequency_range(syslist, Hz=Hz, number_of_samples=omega_num) else: - omega_limits = np.array(omega_limits) + omega_limits = np.asarray(omega_limits) + if len(omega_limits) != 2: + raise ValueError("len(omega_limits) must be 2") if Hz: omega_limits *= 2. * math.pi if omega_num: - omega = np.logspace(np.log10(omega_limits[0]), - np.log10(omega_limits[1]), - num=omega_num, - endpoint=True) + num = omega_num else: - omega = np.logspace(np.log10(omega_limits[0]), - np.log10(omega_limits[1]), - endpoint=True) + num = config.defaults['freqplot.number_of_samples'] + omega = np.logspace(np.log10(omega_limits[0]), + np.log10(omega_limits[1]), num=num, + endpoint=True) mags, phases, omegas, nyquistfrqs = [], [], [], [] for sys in syslist: @@ -507,9 +507,9 @@ def gen_zero_centered_series(val_min, val_max, period): # Nyquist plot # -def nyquist_plot(syslist, omega=None, plot=True, label_freq=0, - arrowhead_length=0.1, arrowhead_width=0.1, - color=None, *args, **kwargs): +def nyquist_plot(syslist, omega=None, plot=True, omega_limits=None, + omega_num=None, label_freq=0, arrowhead_length=0.1, + arrowhead_width=0.1, color=None, *args, **kwargs): """ Nyquist plot for a system @@ -519,16 +519,23 @@ def nyquist_plot(syslist, omega=None, plot=True, label_freq=0, ---------- syslist : list of LTI List of linear input/output systems (single system is OK) - omega : freq_range - Range of frequencies (list or bounds) in rad/sec - Plot : boolean + plot : boolean If True, plot magnitude + omega : array_like + Range of frequencies in rad/sec + omega_limits : array_like of two values + Limits of the to generate frequency vector. + omega_num : int + Number of samples to plot. Defaults to + config.defaults['freqplot.number_of_samples']. color : string - Used to specify the color of the plot + Used to specify the color of the line and arrowhead label_freq : int Label every nth frequency on the plot - arrowhead_width : arrow head width - arrowhead_length : arrow head length + arrowhead_width : float + Arrow head width + arrowhead_length : float + Arrow head length *args : :func:`matplotlib.pyplot.plot` positional properties, optional Additional arguments for `matplotlib` plots (color, linestyle, etc) **kwargs : :func:`matplotlib.pyplot.plot` keyword properties, optional @@ -536,12 +543,12 @@ def nyquist_plot(syslist, omega=None, plot=True, label_freq=0, Returns ------- - real : array + real : ndarray real part of the frequency response array - imag : array + imag : ndarray imaginary part of the frequency response array - freq : array - frequencies + freq : ndarray + frequencies in rad/s Examples -------- @@ -571,7 +578,21 @@ def nyquist_plot(syslist, omega=None, plot=True, label_freq=0, # Select a default range if none is provided if omega is None: - omega = default_frequency_range(syslist) + if omega_limits is None: + # Select a default range if none is provided + omega = default_frequency_range(syslist, Hz=False, + number_of_samples=omega_num) + else: + omega_limits = np.asarray(omega_limits) + if len(omega_limits) != 2: + raise ValueError("len(omega_limits) must be 2") + if omega_num: + num = omega_num + else: + num = config.defaults['freqplot.number_of_samples'] + omega = np.logspace(np.log10(omega_limits[0]), + np.log10(omega_limits[1]), num=num, + endpoint=True) # Interpolate between wmin and wmax if a tuple or list are provided elif isinstance(omega, list) or isinstance(omega, tuple): @@ -580,65 +601,61 @@ def nyquist_plot(syslist, omega=None, plot=True, label_freq=0, raise ValueError("Supported frequency arguments are (wmin,wmax)" "tuple or list, or frequency vector. ") omega = np.logspace(np.log10(omega[0]), np.log10(omega[1]), - num=50, endpoint=True, base=10.0) + num=500, endpoint=True, base=10.0) for sys in syslist: if not sys.issiso(): # TODO: Add MIMO nyquist plots. raise ControlMIMONotImplemented( "Nyquist is currently only implemented for SISO systems.") - else: - # Get the magnitude and phase of the system - mag_tmp, phase_tmp, omega = sys.frequency_response(omega) - mag = np.squeeze(mag_tmp) - phase = np.squeeze(phase_tmp) - - # Compute the primary curve - x = np.multiply(mag, np.cos(phase)) - y = np.multiply(mag, np.sin(phase)) - if plot: - # Plot the primary curve and mirror image - p = plt.plot(x, y, '-', color=color, *args, **kwargs) - c = p[0].get_color() - ax = plt.gca() - # Plot arrow to indicate Nyquist encirclement orientation - ax.arrow(x[0], y[0], (x[1]-x[0])/2, (y[1]-y[0])/2, fc=c, ec=c, - head_width=arrowhead_width, - head_length=arrowhead_length) - - plt.plot(x, -y, '-', color=c, *args, **kwargs) - ax.arrow( - x[-1], -y[-1], (x[-1]-x[-2])/2, (y[-1]-y[-2])/2, - fc=c, ec=c, head_width=arrowhead_width, - head_length=arrowhead_length) - - # Mark the -1 point - plt.plot([-1], [0], 'r+') - - # Label the frequencies of the points - if label_freq: - ind = slice(None, None, label_freq) - for xpt, ypt, omegapt in zip(x[ind], y[ind], omega[ind]): - # Convert to Hz - f = omegapt / (2 * np.pi) - - # Factor out multiples of 1000 and limit the - # result to the range [-8, 8]. - pow1000 = max(min(get_pow1000(f), 8), -8) - - # Get the SI prefix. - prefix = gen_prefix(pow1000) - - # Apply the text. (Use a space before the text to - # prevent overlap with the data.) - # - # np.round() is used because 0.99... appears - # instead of 1.0, and this would otherwise be - # truncated to 0. - plt.text(xpt, ypt, ' ' + - str(int(np.round(f / 1000 ** pow1000, 0))) + ' ' + - prefix + 'Hz') + # Get the magnitude and phase of the system + mag, phase, omega = sys.frequency_response(omega) + + # Compute the primary curve + x = mag * np.cos(phase) + y = mag * np.sin(phase) + + if plot: + # Plot the primary curve and mirror image + p = plt.plot(np.hstack((x,x)), np.hstack((y,-y)), + '-', color=color, *args, **kwargs) + c = p[0].get_color() + ax = plt.gca() + # Plot arrow to indicate Nyquist encirclement orientation + ax.arrow(x[0], y[0], (x[1]-x[0])/2, (y[1]-y[0])/2, + fc=c, ec=c, head_width=arrowhead_width, + head_length=arrowhead_length, label=None) + ax.arrow(x[-1], -y[-1], (x[-1]-x[-2])/2, (y[-1]-y[-2])/2, + fc=c, ec=c, head_width=arrowhead_width, + head_length=arrowhead_length, label=None) + + # Mark the -1 point + plt.plot([-1], [0], 'r+') + + # Label the frequencies of the points + if label_freq: + ind = slice(None, None, label_freq) + for xpt, ypt, omegapt in zip(x[ind], y[ind], omega[ind]): + # Convert to Hz + f = omegapt / (2 * np.pi) + + # Factor out multiples of 1000 and limit the + # result to the range [-8, 8]. + pow1000 = max(min(get_pow1000(f), 8), -8) + + # Get the SI prefix. + prefix = gen_prefix(pow1000) + + # Apply the text. (Use a space before the text to + # prevent overlap with the data.) + # + # np.round() is used because 0.99... appears + # instead of 1.0, and this would otherwise be + # truncated to 0. + plt.text(xpt, ypt, ' ' + + str(int(np.round(f / 1000 ** pow1000, 0))) + ' ' + + prefix + 'Hz') if plot: ax = plt.gca() From 08dfca560cc4d3029023dfc35e2e3532c7508943 Mon Sep 17 00:00:00 2001 From: Sawyer Fuller Date: Thu, 28 Jan 2021 10:11:02 -0800 Subject: [PATCH 10/21] convert first system passed to append into ss if necessary --- control/bdalg.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/control/bdalg.py b/control/bdalg.py index 20c9f4b09..10d49f130 100644 --- a/control/bdalg.py +++ b/control/bdalg.py @@ -54,6 +54,7 @@ """ import numpy as np +from scipy.signal.ltisys import StateSpace from . import xferfcn as tf from . import statesp as ss from . import frdata as frd @@ -280,7 +281,10 @@ def append(*sys): >>> sys = append(sys1, sys2) """ - s1 = sys[0] + if not isinstance(sys[0], StateSpace): + s1 = ss._convert_to_statespace(sys[0]) + else: + s1 = sys[0] for s in sys[1:]: s1 = s1.append(s) return s1 From d8b70ed9fd97ac20098992b54f4f1c7e1931cbb9 Mon Sep 17 00:00:00 2001 From: Sawyer Fuller Date: Thu, 28 Jan 2021 13:23:01 -0800 Subject: [PATCH 11/21] a few small code cleanups --- control/freqplot.py | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/control/freqplot.py b/control/freqplot.py index c9d9d2899..2e476483e 100644 --- a/control/freqplot.py +++ b/control/freqplot.py @@ -220,18 +220,17 @@ def bode_plot(syslist, omega=None, raise ControlMIMONotImplemented( "Bode is currently only implemented for SISO systems.") else: - omega_sys = np.array(omega) - if sys.isdtime(True): + omega_sys = np.asarray(omega) + if sys.isdtime(strict=True): nyquistfrq = 2. * math.pi * 1. / sys.dt / 2. omega_sys = omega_sys[omega_sys < nyquistfrq] # TODO: What distance to the Nyquist frequency is appropriate? else: nyquistfrq = None - # Get the magnitude and phase of the system - mag_tmp, phase_tmp, omega_sys = sys.frequency_response(omega_sys) - mag = np.atleast_1d(np.squeeze(mag_tmp)) - phase = np.atleast_1d(np.squeeze(phase_tmp)) + mag, phase, omega_sys = sys.frequency_response(omega_sys) + mag = np.atleast_1d(mag) + phase = np.atleast_1d(phase) # # Post-process the phase to handle initial value and wrapping @@ -352,8 +351,7 @@ def bode_plot(syslist, omega=None, # Show the phase and gain margins in the plot if margins: # Compute stability margins for the system - margin = stability_margins(sys) - gm, pm, Wcg, Wcp = (margin[i] for i in (0, 1, 3, 4)) + gm, pm, Wcg, Wcp = stability_margins(sys)[0:4] # Figure out sign of the phase at the first gain crossing # (needed if phase_wrap is True) From 5e3c2bb344d391fa1ff2187be16bac8b92b0e9b4 Mon Sep 17 00:00:00 2001 From: Sawyer Fuller Date: Thu, 28 Jan 2021 13:55:06 -0800 Subject: [PATCH 12/21] fixes to pass tests --- control/freqplot.py | 3 ++- control/tests/config_test.py | 2 +- control/tests/sisotool_test.py | 4 ++-- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/control/freqplot.py b/control/freqplot.py index 2e476483e..8a4e41d30 100644 --- a/control/freqplot.py +++ b/control/freqplot.py @@ -351,7 +351,8 @@ def bode_plot(syslist, omega=None, # Show the phase and gain margins in the plot if margins: # Compute stability margins for the system - gm, pm, Wcg, Wcp = stability_margins(sys)[0:4] + margin = stability_margins(sys) + gm, pm, Wcg, Wcp = (margin[i] for i in (0, 1, 3, 4)) # Figure out sign of the phase at the first gain crossing # (needed if phase_wrap is True) diff --git a/control/tests/config_test.py b/control/tests/config_test.py index 02d0ad51c..b64240064 100644 --- a/control/tests/config_test.py +++ b/control/tests/config_test.py @@ -203,7 +203,7 @@ def test_reset_defaults(self): assert not ct.config.defaults['bode.dB'] assert ct.config.defaults['bode.deg'] assert not ct.config.defaults['bode.Hz'] - assert ct.config.defaults['freqplot.number_of_samples'] is None + assert ct.config.defaults['freqplot.number_of_samples'] is 1000 assert ct.config.defaults['freqplot.feature_periphery_decades'] == 1.0 def test_legacy_defaults(self): diff --git a/control/tests/sisotool_test.py b/control/tests/sisotool_test.py index 65f87f28b..09c73179f 100644 --- a/control/tests/sisotool_test.py +++ b/control/tests/sisotool_test.py @@ -78,8 +78,8 @@ def test_sisotool(self, sys): # Check if the bode_mag line has moved bode_mag_moved = np.array( - [111.83321224, 92.29238035, 76.02822315, 62.46884113, 51.14108703, - 41.6554004, 33.69409534, 27.00237344, 21.38086717, 16.67791585]) + [674.0242, 667.8354, 661.7033, 655.6275, 649.6074, 643.6426, + 637.7324, 631.8765, 626.0742, 620.3252]) assert_array_almost_equal(ax_mag.lines[0].get_data()[1][10:20], bode_mag_moved, 4) From 41193c6255a450996012b5880253ab2cc6d15691 Mon Sep 17 00:00:00 2001 From: Sawyer Fuller Date: Thu, 28 Jan 2021 14:43:09 -0800 Subject: [PATCH 13/21] test bug, changed is to == --- control/tests/config_test.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/control/tests/config_test.py b/control/tests/config_test.py index b64240064..b36b6b313 100644 --- a/control/tests/config_test.py +++ b/control/tests/config_test.py @@ -203,7 +203,7 @@ def test_reset_defaults(self): assert not ct.config.defaults['bode.dB'] assert ct.config.defaults['bode.deg'] assert not ct.config.defaults['bode.Hz'] - assert ct.config.defaults['freqplot.number_of_samples'] is 1000 + assert ct.config.defaults['freqplot.number_of_samples'] == 1000 assert ct.config.defaults['freqplot.feature_periphery_decades'] == 1.0 def test_legacy_defaults(self): From d195e06a6e652e9ca9409f300c20f20b60d62329 Mon Sep 17 00:00:00 2001 From: Sawyer Fuller Date: Thu, 28 Jan 2021 15:18:37 -0800 Subject: [PATCH 14/21] revert some freqplot.nyquist_plot changes because they turned out to be unneccesary and to avoid a merge conbflict with #521 --- control/freqplot.py | 99 +++++++++++++++++++++------------------------ 1 file changed, 46 insertions(+), 53 deletions(-) diff --git a/control/freqplot.py b/control/freqplot.py index 8a4e41d30..5c1360835 100644 --- a/control/freqplot.py +++ b/control/freqplot.py @@ -593,66 +593,59 @@ def nyquist_plot(syslist, omega=None, plot=True, omega_limits=None, np.log10(omega_limits[1]), num=num, endpoint=True) - # Interpolate between wmin and wmax if a tuple or list are provided - elif isinstance(omega, list) or isinstance(omega, tuple): - # Only accept tuple or list of length 2 - if len(omega) != 2: - raise ValueError("Supported frequency arguments are (wmin,wmax)" - "tuple or list, or frequency vector. ") - omega = np.logspace(np.log10(omega[0]), np.log10(omega[1]), - num=500, endpoint=True, base=10.0) for sys in syslist: if not sys.issiso(): # TODO: Add MIMO nyquist plots. raise ControlMIMONotImplemented( "Nyquist is currently only implemented for SISO systems.") + else: + # Get the magnitude and phase of the system + mag, phase, omega = sys.frequency_response(omega) - # Get the magnitude and phase of the system - mag, phase, omega = sys.frequency_response(omega) - - # Compute the primary curve - x = mag * np.cos(phase) - y = mag * np.sin(phase) - - if plot: - # Plot the primary curve and mirror image - p = plt.plot(np.hstack((x,x)), np.hstack((y,-y)), - '-', color=color, *args, **kwargs) - c = p[0].get_color() - ax = plt.gca() - # Plot arrow to indicate Nyquist encirclement orientation - ax.arrow(x[0], y[0], (x[1]-x[0])/2, (y[1]-y[0])/2, - fc=c, ec=c, head_width=arrowhead_width, - head_length=arrowhead_length, label=None) - ax.arrow(x[-1], -y[-1], (x[-1]-x[-2])/2, (y[-1]-y[-2])/2, - fc=c, ec=c, head_width=arrowhead_width, - head_length=arrowhead_length, label=None) - - # Mark the -1 point - plt.plot([-1], [0], 'r+') - - # Label the frequencies of the points - if label_freq: - ind = slice(None, None, label_freq) - for xpt, ypt, omegapt in zip(x[ind], y[ind], omega[ind]): - # Convert to Hz - f = omegapt / (2 * np.pi) - - # Factor out multiples of 1000 and limit the - # result to the range [-8, 8]. - pow1000 = max(min(get_pow1000(f), 8), -8) - - # Get the SI prefix. - prefix = gen_prefix(pow1000) - - # Apply the text. (Use a space before the text to - # prevent overlap with the data.) - # - # np.round() is used because 0.99... appears - # instead of 1.0, and this would otherwise be - # truncated to 0. - plt.text(xpt, ypt, ' ' + + # Compute the primary curve + x = mag * np.cos(phase) + y = mag * np.sin(phase) + + if plot: + # Plot the primary curve and mirror image + p = plt.plot(x, y, '-', color=color, *args, **kwargs) + c = p[0].get_color() + ax = plt.gca() + # Plot arrow to indicate Nyquist encirclement orientation + ax.arrow(x[0], y[0], (x[1]-x[0])/2, (y[1]-y[0])/2, fc=c, ec=c, + head_width=arrowhead_width, + head_length=arrowhead_length) + + plt.plot(x, -y, '-', color=c, *args, **kwargs) + ax.arrow( + x[-1], -y[-1], (x[-1]-x[-2])/2, (y[-1]-y[-2])/2, + fc=c, ec=c, head_width=arrowhead_width, + head_length=arrowhead_length) + # Mark the -1 point + plt.plot([-1], [0], 'r+') + + # Label the frequencies of the points + if label_freq: + ind = slice(None, None, label_freq) + for xpt, ypt, omegapt in zip(x[ind], y[ind], omega[ind]): + # Convert to Hz + f = omegapt / (2 * np.pi) + + # Factor out multiples of 1000 and limit the + # result to the range [-8, 8]. + pow1000 = max(min(get_pow1000(f), 8), -8) + + # Get the SI prefix. + prefix = gen_prefix(pow1000) + + # Apply the text. (Use a space before the text to + # prevent overlap with the data.) + # + # np.round() is used because 0.99... appears + # instead of 1.0, and this would otherwise be + # truncated to 0. + plt.text(xpt, ypt, ' ' + str(int(np.round(f / 1000 ** pow1000, 0))) + ' ' + prefix + 'Hz') From eaf5b160388a1bc0ca47f5e180769581bfa34db9 Mon Sep 17 00:00:00 2001 From: Sawyer Fuller Date: Thu, 28 Jan 2021 19:10:15 -0800 Subject: [PATCH 15/21] docstring fixes, nyquist now outputs frequency response as specified in docstring --- control/freqplot.py | 87 ++++++++++++++++++++++++--------------------- 1 file changed, 46 insertions(+), 41 deletions(-) diff --git a/control/freqplot.py b/control/freqplot.py index 5c1360835..73508a4f7 100644 --- a/control/freqplot.py +++ b/control/freqplot.py @@ -121,11 +121,11 @@ def bode_plot(syslist, omega=None, Returns ------- - mag : array (list if len(syslist) > 1) + mag : ndarray (or list of ndarray if len(syslist) > 1)) magnitude - phase : array (list if len(syslist) > 1) + phase : ndarray (or list of ndarray if len(syslist) > 1)) phase in radians - omega : array (list if len(syslist) > 1) + omega : ndarray (or list of ndarray if len(syslist) > 1)) frequency in rad/sec Other Parameters @@ -190,8 +190,8 @@ def bode_plot(syslist, omega=None, initial_phase = config._get_param( 'bode', 'initial_phase', kwargs, None, pop=True) - # If argument was a singleton, turn it into a list - if not getattr(syslist, '__iter__', False): + # If argument was a singleton, turn it into a tuple + if not hasattr(syslist, '__iter__'): syslist = (syslist,) if omega is None: @@ -542,11 +542,11 @@ def nyquist_plot(syslist, omega=None, plot=True, omega_limits=None, Returns ------- - real : ndarray + real : ndarray (or list of ndarray if len(syslist) > 1)) real part of the frequency response array - imag : ndarray + imag : ndarray (or list of ndarray if len(syslist) > 1)) imaginary part of the frequency response array - freq : ndarray + omega : ndarray (or list of ndarray if len(syslist) > 1)) frequencies in rad/s Examples @@ -572,7 +572,7 @@ def nyquist_plot(syslist, omega=None, plot=True, omega_limits=None, label_freq = kwargs.pop('labelFreq') # If argument was a singleton, turn it into a list - if not getattr(syslist, '__iter__', False): + if not hasattr(syslist, '__iter__'): syslist = (syslist,) # Select a default range if none is provided @@ -593,37 +593,40 @@ def nyquist_plot(syslist, omega=None, plot=True, omega_limits=None, np.log10(omega_limits[1]), num=num, endpoint=True) - + xs, ys, omegas = [], [], [] for sys in syslist: - if not sys.issiso(): - # TODO: Add MIMO nyquist plots. - raise ControlMIMONotImplemented( - "Nyquist is currently only implemented for SISO systems.") - else: - # Get the magnitude and phase of the system - mag, phase, omega = sys.frequency_response(omega) - - # Compute the primary curve - x = mag * np.cos(phase) - y = mag * np.sin(phase) - - if plot: - # Plot the primary curve and mirror image - p = plt.plot(x, y, '-', color=color, *args, **kwargs) - c = p[0].get_color() - ax = plt.gca() - # Plot arrow to indicate Nyquist encirclement orientation - ax.arrow(x[0], y[0], (x[1]-x[0])/2, (y[1]-y[0])/2, fc=c, ec=c, - head_width=arrowhead_width, - head_length=arrowhead_length) - - plt.plot(x, -y, '-', color=c, *args, **kwargs) - ax.arrow( - x[-1], -y[-1], (x[-1]-x[-2])/2, (y[-1]-y[-2])/2, - fc=c, ec=c, head_width=arrowhead_width, - head_length=arrowhead_length) - # Mark the -1 point - plt.plot([-1], [0], 'r+') + mag, phase, omega = sys.frequency_response(omega) + + # Compute the primary curve + x = mag * np.cos(phase) + y = mag * np.sin(phase) + + xs.append(x) + ys.append(y) + omegas.append(omega) + + if plot: + if not sys.issiso(): + # TODO: Add MIMO nyquist plots. + raise ControlMIMONotImplemented( + "Nyquist plot currently supports SISO systems.") + + # Plot the primary curve and mirror image + p = plt.plot(x, y, '-', color=color, *args, **kwargs) + c = p[0].get_color() + ax = plt.gca() + # Plot arrow to indicate Nyquist encirclement orientation + ax.arrow(x[0], y[0], (x[1]-x[0])/2, (y[1]-y[0])/2, fc=c, ec=c, + head_width=arrowhead_width, + head_length=arrowhead_length) + + plt.plot(x, -y, '-', color=c, *args, **kwargs) + ax.arrow( + x[-1], -y[-1], (x[-1]-x[-2])/2, (y[-1]-y[-2])/2, + fc=c, ec=c, head_width=arrowhead_width, + head_length=arrowhead_length) + # Mark the -1 point + plt.plot([-1], [0], 'r+') # Label the frequencies of the points if label_freq: @@ -655,8 +658,10 @@ def nyquist_plot(syslist, omega=None, plot=True, omega_limits=None, ax.set_ylabel("Imaginary axis") ax.grid(color="lightgray") - return x, y, omega - + if len(syslist) == 1: + return xs[0], ys[0], omegas[0] + else: + return xs, ys, omegas # # Gang of Four plot From e8d233f083b9c5f6bdbf221fda6daf9badf22555 Mon Sep 17 00:00:00 2001 From: Sawyer Fuller Date: Thu, 28 Jan 2021 22:42:43 -0800 Subject: [PATCH 16/21] added a few unit tests for frequency range parameter to nyquist and bode --- control/tests/freqresp_test.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/control/tests/freqresp_test.py b/control/tests/freqresp_test.py index 5c7c2cd80..86de0e77a 100644 --- a/control/tests/freqresp_test.py +++ b/control/tests/freqresp_test.py @@ -16,6 +16,7 @@ from control.statesp import StateSpace from control.xferfcn import TransferFunction from control.matlab import ss, tf, bode, rss +from control.freqplot import bode_plot, nyquist_plot from control.tests.conftest import slycotonly pytestmark = pytest.mark.usefixtures("mplcleanup") @@ -61,6 +62,24 @@ def test_bode_basic(ss_siso): tf_siso = tf(ss_siso) bode(ss_siso) bode(tf_siso) + assert len(bode_plot(tf_siso, plot=False, omega_num=20)[0] == 20) + omega = bode_plot(tf_siso, plot=False, omega_limits=(1, 100))[2] + assert_allclose(omega[0], 1) + assert_allclose(omega[-1], 100) + assert len(bode_plot(tf_siso, plot=False, omega=np.logspace(-1,1,10))[0])\ + == 10 + +def test_nyquist_basic(ss_siso): + """Test nyquist plot call (Very basic)""" + # TODO: proper test + tf_siso = tf(ss_siso) + nyquist_plot(ss_siso) + nyquist_plot(tf_siso) + assert len(nyquist_plot(tf_siso, plot=False, omega_num=20)[0] == 20) + omega = nyquist_plot(tf_siso, plot=False, omega_limits=(1, 100))[2] + assert_allclose(omega[0], 1) + assert_allclose(omega[-1], 100) + assert len(nyquist_plot(tf_siso, plot=False, omega=np.logspace(-1, 1, 10))[0])==10 @pytest.mark.filterwarnings("ignore:.*non-positive left xlim:UserWarning") From fa4378580b9a8fda2dfc18dc80cc48ac28b657d9 Mon Sep 17 00:00:00 2001 From: Sawyer Fuller Date: Thu, 28 Jan 2021 23:41:30 -0800 Subject: [PATCH 17/21] plot vertical nyquist freq line at same time as data for legend convenience eg legend(('sys1','sys2')) --- control/freqplot.py | 114 ++++++++++++++++++++++++-------------------- 1 file changed, 63 insertions(+), 51 deletions(-) diff --git a/control/freqplot.py b/control/freqplot.py index 73508a4f7..e2d1b6eb7 100644 --- a/control/freqplot.py +++ b/control/freqplot.py @@ -194,7 +194,9 @@ def bode_plot(syslist, omega=None, if not hasattr(syslist, '__iter__'): syslist = (syslist,) + if omega is None: + omega_was_given = False # used do decide whether to include nyq. freq if omega_limits is None: # Select a default range if none is provided omega = default_frequency_range(syslist, Hz=Hz, @@ -212,6 +214,46 @@ def bode_plot(syslist, omega=None, omega = np.logspace(np.log10(omega_limits[0]), np.log10(omega_limits[1]), num=num, endpoint=True) + else: + omega_was_given = True + + if plot: + # Set up the axes with labels so that multiple calls to + # bode_plot will superimpose the data. This was implicit + # before matplotlib 2.1, but changed after that (See + # https://github.com/matplotlib/matplotlib/issues/9024). + # The code below should work on all cases. + + # Get the current figure + + if 'sisotool' in kwargs: + fig = kwargs['fig'] + ax_mag = fig.axes[0] + ax_phase = fig.axes[2] + sisotool = kwargs['sisotool'] + del kwargs['fig'] + del kwargs['sisotool'] + else: + fig = plt.gcf() + ax_mag = None + ax_phase = None + sisotool = False + + # Get the current axes if they already exist + for ax in fig.axes: + if ax.get_label() == 'control-bode-magnitude': + ax_mag = ax + elif ax.get_label() == 'control-bode-phase': + ax_phase = ax + + # If no axes present, create them from scratch + if ax_mag is None or ax_phase is None: + plt.clf() + ax_mag = plt.subplot(211, + label='control-bode-magnitude') + ax_phase = plt.subplot(212, + label='control-bode-phase', + sharex=ax_mag) mags, phases, omegas, nyquistfrqs = [], [], [], [] for sys in syslist: @@ -223,8 +265,11 @@ def bode_plot(syslist, omega=None, omega_sys = np.asarray(omega) if sys.isdtime(strict=True): nyquistfrq = 2. * math.pi * 1. / sys.dt / 2. - omega_sys = omega_sys[omega_sys < nyquistfrq] - # TODO: What distance to the Nyquist frequency is appropriate? + if not omega_was_given: + # include nyquist frequency + omega_sys = np.hstack(( + omega_sys[omega_sys < nyquistfrq], + nyquistfrq)) else: nyquistfrq = None @@ -285,56 +330,28 @@ def bode_plot(syslist, omega=None, omega_plot = omega_sys if nyquistfrq: nyquistfrq_plot = nyquistfrq - - # Set up the axes with labels so that multiple calls to - # bode_plot will superimpose the data. This was implicit - # before matplotlib 2.1, but changed after that (See - # https://github.com/matplotlib/matplotlib/issues/9024). - # The code below should work on all cases. - - # Get the current figure - - if 'sisotool' in kwargs: - fig = kwargs['fig'] - ax_mag = fig.axes[0] - ax_phase = fig.axes[2] - sisotool = kwargs['sisotool'] - del kwargs['fig'] - del kwargs['sisotool'] - else: - fig = plt.gcf() - ax_mag = None - ax_phase = None - sisotool = False - - # Get the current axes if they already exist - for ax in fig.axes: - if ax.get_label() == 'control-bode-magnitude': - ax_mag = ax - elif ax.get_label() == 'control-bode-phase': - ax_phase = ax - - # If no axes present, create them from scratch - if ax_mag is None or ax_phase is None: - plt.clf() - ax_mag = plt.subplot(211, - label='control-bode-magnitude') - ax_phase = plt.subplot(212, - label='control-bode-phase', - sharex=ax_mag) - + phase_plot = phase * 180. / math.pi if deg else phase + mag_plot = mag # # Magnitude plot # + + if nyquistfrq_plot: + # add data for vertical nyquist freq indicator line + # so it is a single plot action. This preserves line + # order when creating legend eg. legend('sys1', 'sys2) + omega_plot = np.hstack((omega_plot, nyquistfrq,nyquistfrq)) + mag_plot = np.hstack((mag_plot, + 0.7*min(mag_plot),1.3*max(mag_plot))) + phase_range = max(phase_plot) - min(phase_plot) + phase_plot = np.hstack((phase_plot, + min(phase_plot) - 0.2 * phase_range, + max(phase_plot) + 0.2 * phase_range)) if dB: - pltline = ax_mag.semilogx(omega_plot, 20 * np.log10(mag), + ax_mag.semilogx(omega_plot, 20 * np.log10(mag_plot), *args, **kwargs) else: - pltline = ax_mag.loglog(omega_plot, mag, *args, **kwargs) - - if nyquistfrq_plot: - ax_mag.axvline(nyquistfrq_plot, - color=pltline[0].get_color()) + ax_mag.loglog(omega_plot, mag_plot, *args, **kwargs) # Add a grid to the plot + labeling ax_mag.grid(grid and not margins, which='both') @@ -343,7 +360,6 @@ def bode_plot(syslist, omega=None, # # Phase plot # - phase_plot = phase * 180. / math.pi if deg else phase # Plot the data ax_phase.semilogx(omega_plot, phase_plot, *args, **kwargs) @@ -463,10 +479,6 @@ def bode_plot(syslist, omega=None, 'deg' if deg else 'rad', Wcp, 'Hz' if Hz else 'rad/s')) - if nyquistfrq_plot: - ax_phase.axvline( - nyquistfrq_plot, color=pltline[0].get_color()) - # Add a grid to the plot + labeling ax_phase.set_ylabel("Phase (deg)" if deg else "Phase (rad)") From adec95e5c371dd472789e24c6f03da8b1c0e7390 Mon Sep 17 00:00:00 2001 From: sawyerbfuller <58706249+sawyerbfuller@users.noreply.github.com> Date: Thu, 28 Jan 2021 23:45:59 -0800 Subject: [PATCH 18/21] Update control/freqplot.py --- control/freqplot.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/control/freqplot.py b/control/freqplot.py index e2d1b6eb7..e6f73bdb5 100644 --- a/control/freqplot.py +++ b/control/freqplot.py @@ -533,7 +533,7 @@ def nyquist_plot(syslist, omega=None, plot=True, omega_limits=None, plot : boolean If True, plot magnitude omega : array_like - Range of frequencies in rad/sec + Set of frequencies to be evaluated in rad/sec. omega_limits : array_like of two values Limits of the to generate frequency vector. omega_num : int From b7653f866eb0b31cad22419d973b94cf9e467f6e Mon Sep 17 00:00:00 2001 From: sawyerbfuller <58706249+sawyerbfuller@users.noreply.github.com> Date: Thu, 28 Jan 2021 23:46:09 -0800 Subject: [PATCH 19/21] Update control/freqplot.py --- control/freqplot.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/control/freqplot.py b/control/freqplot.py index e6f73bdb5..09e839ac8 100644 --- a/control/freqplot.py +++ b/control/freqplot.py @@ -535,7 +535,8 @@ def nyquist_plot(syslist, omega=None, plot=True, omega_limits=None, omega : array_like Set of frequencies to be evaluated in rad/sec. omega_limits : array_like of two values - Limits of the to generate frequency vector. + Limits to the range of frequencies. Ignored if omega + is provided, and auto-generated if omitted. omega_num : int Number of samples to plot. Defaults to config.defaults['freqplot.number_of_samples']. From acaea54e259f0dd2a8d892ba4e97ab50fa39af6a Mon Sep 17 00:00:00 2001 From: Sawyer Fuller Date: Fri, 29 Jan 2021 00:04:31 -0800 Subject: [PATCH 20/21] @murrayrm suggested changes e.g change default_frequency_range to private --- control/bdalg.py | 8 ++------ control/freqplot.py | 16 +++++++--------- control/nichols.py | 4 ++-- 3 files changed, 11 insertions(+), 17 deletions(-) diff --git a/control/bdalg.py b/control/bdalg.py index 10d49f130..2c5c12642 100644 --- a/control/bdalg.py +++ b/control/bdalg.py @@ -54,7 +54,6 @@ """ import numpy as np -from scipy.signal.ltisys import StateSpace from . import xferfcn as tf from . import statesp as ss from . import frdata as frd @@ -175,7 +174,7 @@ def negate(sys): >>> sys2 = negate(sys1) # Same as sys2 = -sys1. """ - return -sys; + return -sys #! TODO: expand to allow sys2 default to work in MIMO case? def feedback(sys1, sys2=1, sign=-1): @@ -281,10 +280,7 @@ def append(*sys): >>> sys = append(sys1, sys2) """ - if not isinstance(sys[0], StateSpace): - s1 = ss._convert_to_statespace(sys[0]) - else: - s1 = sys[0] + s1 = ss._convert_to_statespace(sys[0]) for s in sys[1:]: s1 = s1.append(s) return s1 diff --git a/control/freqplot.py b/control/freqplot.py index e2d1b6eb7..7247270e2 100644 --- a/control/freqplot.py +++ b/control/freqplot.py @@ -199,7 +199,7 @@ def bode_plot(syslist, omega=None, omega_was_given = False # used do decide whether to include nyq. freq if omega_limits is None: # Select a default range if none is provided - omega = default_frequency_range(syslist, Hz=Hz, + omega = _default_frequency_range(syslist, Hz=Hz, number_of_samples=omega_num) else: omega_limits = np.asarray(omega_limits) @@ -591,16 +591,14 @@ def nyquist_plot(syslist, omega=None, plot=True, omega_limits=None, if omega is None: if omega_limits is None: # Select a default range if none is provided - omega = default_frequency_range(syslist, Hz=False, + omega = _default_frequency_range(syslist, Hz=False, number_of_samples=omega_num) else: omega_limits = np.asarray(omega_limits) if len(omega_limits) != 2: raise ValueError("len(omega_limits) must be 2") - if omega_num: - num = omega_num - else: - num = config.defaults['freqplot.number_of_samples'] + num = \ + ct.config._get_param('freqplot','number_of_samples', omega_num) omega = np.logspace(np.log10(omega_limits[0]), np.log10(omega_limits[1]), num=num, endpoint=True) @@ -717,7 +715,7 @@ def gangof4_plot(P, C, omega=None, **kwargs): # Select a default range if none is provided # TODO: This needs to be made more intelligent if omega is None: - omega = default_frequency_range((P, C, S)) + omega = _default_frequency_range((P, C, S)) # Set up the axes with labels so that multiple calls to # gangof4_plot will superimpose the data. See details in bode_plot. @@ -798,7 +796,7 @@ def gangof4_plot(P, C, omega=None, **kwargs): # # Compute reasonable defaults for axes -def default_frequency_range(syslist, Hz=None, number_of_samples=None, +def _default_frequency_range(syslist, Hz=None, number_of_samples=None, feature_periphery_decades=None): """Compute a reasonable default frequency range for frequency domain plots. @@ -832,7 +830,7 @@ def default_frequency_range(syslist, Hz=None, number_of_samples=None, -------- >>> from matlab import ss >>> sys = ss("1. -2; 3. -4", "5.; 7", "6. 8", "9.") - >>> omega = default_frequency_range(sys) + >>> omega = _default_frequency_range(sys) """ # This code looks at the poles and zeros of all of the systems that diff --git a/control/nichols.py b/control/nichols.py index c1d8ff9b6..a643d8580 100644 --- a/control/nichols.py +++ b/control/nichols.py @@ -52,7 +52,7 @@ import numpy as np import matplotlib.pyplot as plt from .ctrlutil import unwrap -from .freqplot import default_frequency_range +from .freqplot import _default_frequency_range from . import config __all__ = ['nichols_plot', 'nichols', 'nichols_grid'] @@ -91,7 +91,7 @@ def nichols_plot(sys_list, omega=None, grid=None): # Select a default range if none is provided if omega is None: - omega = default_frequency_range(sys_list) + omega = _default_frequency_range(sys_list) for sys in sys_list: # Get the magnitude and phase of the system From 73ce1a67be9e8e341441241e11a6f9ae3d0f23c5 Mon Sep 17 00:00:00 2001 From: Sawyer Fuller Date: Fri, 29 Jan 2021 09:31:09 -0800 Subject: [PATCH 21/21] allow specified frequency range to exceed nyquist frequency if desired --- control/freqplot.py | 84 ++++++++++++++++++++++++++------------------- 1 file changed, 48 insertions(+), 36 deletions(-) diff --git a/control/freqplot.py b/control/freqplot.py index 450770b03..ce337844a 100644 --- a/control/freqplot.py +++ b/control/freqplot.py @@ -194,28 +194,25 @@ def bode_plot(syslist, omega=None, if not hasattr(syslist, '__iter__'): syslist = (syslist,) + # decide whether to go above nyquist. freq + omega_range_given = True if omega is not None else False if omega is None: - omega_was_given = False # used do decide whether to include nyq. freq + omega_num = config._get_param('freqplot','number_of_samples', omega_num) if omega_limits is None: # Select a default range if none is provided - omega = _default_frequency_range(syslist, Hz=Hz, - number_of_samples=omega_num) + omega = _default_frequency_range(syslist, + number_of_samples=omega_num) else: + omega_range_given = True omega_limits = np.asarray(omega_limits) if len(omega_limits) != 2: raise ValueError("len(omega_limits) must be 2") if Hz: omega_limits *= 2. * math.pi - if omega_num: - num = omega_num - else: - num = config.defaults['freqplot.number_of_samples'] omega = np.logspace(np.log10(omega_limits[0]), - np.log10(omega_limits[1]), num=num, + np.log10(omega_limits[1]), num=omega_num, endpoint=True) - else: - omega_was_given = True if plot: # Set up the axes with labels so that multiple calls to @@ -264,12 +261,11 @@ def bode_plot(syslist, omega=None, else: omega_sys = np.asarray(omega) if sys.isdtime(strict=True): - nyquistfrq = 2. * math.pi * 1. / sys.dt / 2. - if not omega_was_given: - # include nyquist frequency + nyquistfrq = math.pi / sys.dt + if not omega_range_given: + # limit up to and including nyquist frequency omega_sys = np.hstack(( - omega_sys[omega_sys < nyquistfrq], - nyquistfrq)) + omega_sys[omega_sys < nyquistfrq], nyquistfrq)) else: nyquistfrq = None @@ -332,21 +328,27 @@ def bode_plot(syslist, omega=None, nyquistfrq_plot = nyquistfrq phase_plot = phase * 180. / math.pi if deg else phase mag_plot = mag - # - # Magnitude plot - # if nyquistfrq_plot: - # add data for vertical nyquist freq indicator line - # so it is a single plot action. This preserves line - # order when creating legend eg. legend('sys1', 'sys2) - omega_plot = np.hstack((omega_plot, nyquistfrq,nyquistfrq)) - mag_plot = np.hstack((mag_plot, - 0.7*min(mag_plot),1.3*max(mag_plot))) + # append data for vertical nyquist freq indicator line. + # if this extra nyquist lime is is plotted in a single plot + # command then line order is preserved when + # creating a legend eg. legend(('sys1', 'sys2')) + omega_nyq_line = np.array((np.nan, nyquistfrq, nyquistfrq)) + omega_plot = np.hstack((omega_plot, omega_nyq_line)) + mag_nyq_line = np.array(( + np.nan, 0.7*min(mag_plot), 1.3*max(mag_plot))) + mag_plot = np.hstack((mag_plot, mag_nyq_line)) phase_range = max(phase_plot) - min(phase_plot) - phase_plot = np.hstack((phase_plot, + phase_nyq_line = np.array((np.nan, min(phase_plot) - 0.2 * phase_range, max(phase_plot) + 0.2 * phase_range)) + phase_plot = np.hstack((phase_plot, phase_nyq_line)) + + # + # Magnitude plot + # + if dB: ax_mag.semilogx(omega_plot, 20 * np.log10(mag_plot), *args, **kwargs) @@ -535,8 +537,8 @@ def nyquist_plot(syslist, omega=None, plot=True, omega_limits=None, omega : array_like Set of frequencies to be evaluated in rad/sec. omega_limits : array_like of two values - Limits to the range of frequencies. Ignored if omega - is provided, and auto-generated if omitted. + Limits to the range of frequencies. Ignored if omega + is provided, and auto-generated if omitted. omega_num : int Number of samples to plot. Defaults to config.defaults['freqplot.number_of_samples']. @@ -588,25 +590,35 @@ def nyquist_plot(syslist, omega=None, plot=True, omega_limits=None, if not hasattr(syslist, '__iter__'): syslist = (syslist,) - # Select a default range if none is provided + # decide whether to go above nyquist. freq + omega_range_given = True if omega is not None else False + if omega is None: + omega_num = config._get_param('freqplot','number_of_samples',omega_num) if omega_limits is None: # Select a default range if none is provided - omega = _default_frequency_range(syslist, Hz=False, - number_of_samples=omega_num) + omega = _default_frequency_range(syslist, + number_of_samples=omega_num) else: + omega_range_given = True omega_limits = np.asarray(omega_limits) if len(omega_limits) != 2: raise ValueError("len(omega_limits) must be 2") - num = \ - ct.config._get_param('freqplot','number_of_samples', omega_num) omega = np.logspace(np.log10(omega_limits[0]), - np.log10(omega_limits[1]), num=num, + np.log10(omega_limits[1]), num=omega_num, endpoint=True) xs, ys, omegas = [], [], [] for sys in syslist: - mag, phase, omega = sys.frequency_response(omega) + omega_sys = np.asarray(omega) + if sys.isdtime(strict=True): + nyquistfrq = math.pi / sys.dt + if not omega_range_given: + # limit up to and including nyquist frequency + omega_sys = np.hstack(( + omega_sys[omega_sys < nyquistfrq], nyquistfrq)) + + mag, phase, omega_sys = sys.frequency_response(omega_sys) # Compute the primary curve x = mag * np.cos(phase) @@ -614,7 +626,7 @@ def nyquist_plot(syslist, omega=None, plot=True, omega_limits=None, xs.append(x) ys.append(y) - omegas.append(omega) + omegas.append(omega_sys) if plot: if not sys.issiso(): @@ -642,7 +654,7 @@ def nyquist_plot(syslist, omega=None, plot=True, omega_limits=None, # Label the frequencies of the points if label_freq: ind = slice(None, None, label_freq) - for xpt, ypt, omegapt in zip(x[ind], y[ind], omega[ind]): + for xpt, ypt, omegapt in zip(x[ind], y[ind], omega_sys[ind]): # Convert to Hz f = omegapt / (2 * np.pi)