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) pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy