From aef709cda19495ba3968454bbca6cbfca0fbcd71 Mon Sep 17 00:00:00 2001 From: Richard Murray Date: Fri, 30 Jun 2023 22:30:58 -0700 Subject: [PATCH 01/17] initial refactoring of bode_plot --- control/frdata.py | 12 +- control/freqplot.py | 888 ++++++++++++++++++++++++++++---------------- control/lti.py | 85 +++-- control/sisotool.py | 3 +- control/timeplot.py | 8 +- 5 files changed, 636 insertions(+), 360 deletions(-) diff --git a/control/frdata.py b/control/frdata.py index 62ac64426..3aafa83db 100644 --- a/control/frdata.py +++ b/control/frdata.py @@ -54,7 +54,7 @@ from .lti import LTI, _process_frequency_response from .exception import pandas_check -from .iosys import InputOutputSystem, _process_iosys_keywords +from .iosys import InputOutputSystem, _process_iosys_keywords, common_timebase from . import config __all__ = ['FrequencyResponseData', 'FRD', 'frd'] @@ -93,6 +93,8 @@ class FrequencyResponseData(LTI): fresp : 3D array Frequency response, indexed by output index, input index, and frequency point. + dt : float, True, or None + System timebase. Notes ----- @@ -169,6 +171,7 @@ def __init__(self, *args, **kwargs): else: z = np.exp(1j * self.omega * otherlti.dt) self.fresp = otherlti(z, squeeze=False) + arg_dt = otherlti.dt else: # The user provided a response and a freq vector @@ -182,6 +185,7 @@ def __init__(self, *args, **kwargs): "The frequency data constructor needs a 1-d or 3-d" " response data array and a matching frequency vector" " size") + arg_dt = None elif len(args) == 1: # Use the copy constructor. @@ -191,6 +195,8 @@ def __init__(self, *args, **kwargs): " an FRD object. Received %s." % type(args[0])) self.omega = args[0].omega self.fresp = args[0].fresp + arg_dt = args[0].dt + else: raise ValueError( "Needs 1 or 2 arguments; received %i." % len(args)) @@ -210,9 +216,11 @@ def __init__(self, *args, **kwargs): # Process iosys keywords defaults = { - 'inputs': self.fresp.shape[1], 'outputs': self.fresp.shape[0]} + 'inputs': self.fresp.shape[1], 'outputs': self.fresp.shape[0], + 'dt': None} name, inputs, outputs, states, dt = _process_iosys_keywords( kwargs, defaults, end=True) + dt = common_timebase(dt, arg_dt) # choose compatible timebase # Process signal names InputOutputSystem.__init__( diff --git a/control/freqplot.py b/control/freqplot.py index 90b390631..6b8135ad6 100644 --- a/control/freqplot.py +++ b/control/freqplot.py @@ -2,6 +2,18 @@ # # Author: Richard M. Murray # Date: 24 May 09 +# +# Functionality to add +# [ ] Get rid of this long header (need some common, documented convention) +# [ ] Add mechanisms for storing/plotting margins? (currently forces FRD) +# [ ] Allow line colors/styles to be set in plot() command (also time plots) +# [ ] Allow bode or nyquist style plots from plot() +# [ ] Allow nyquist_curve() to generate the response curve (?) +# [ ] Allow MIMO frequency plots (w/ mag/phase subplots a la MATLAB) +# [ ] Update sisotool to use ax= +# [ ] Create __main__ in freqplot_test to view results (a la timeplot_test) +# [ ] Get sisotool working in iPython and document how to make it work + # # This file contains some standard control system plots: Bode plots, # Nyquist plots and pole-zero diagrams. The code for Nichols charts @@ -54,14 +66,28 @@ from .margins import stability_margins from .exception import ControlMIMONotImplemented from .statesp import StateSpace +from .lti import frequency_response from .xferfcn import TransferFunction +from .frdata import FrequencyResponseData from . import config __all__ = ['bode_plot', 'nyquist_plot', 'gangof4_plot', 'singular_values_plot', 'bode', 'nyquist', 'gangof4'] +# Default font dictionary +_freqplot_rcParams = mpl.rcParams.copy() +_freqplot_rcParams.update({ + 'axes.labelsize': 'small', + 'axes.titlesize': 'small', + 'figure.titlesize': 'medium', + 'legend.fontsize': 'x-small', + 'xtick.labelsize': 'small', + 'ytick.labelsize': 'small', +}) + # Default values for module parameter variables _freqplot_defaults = { + 'freqplot.rcParams': _freqplot_rcParams, 'freqplot.feature_periphery_decades': 1, 'freqplot.number_of_samples': 1000, 'freqplot.dB': False, # Plot gain in dB @@ -90,55 +116,60 @@ # Bode plot # - -def bode_plot(syslist, omega=None, - plot=True, omega_limits=None, omega_num=None, - margins=None, method='best', *args, **kwargs): +def bode_plot( + data, omega=None, *fmt, ax=None, omega_limits=None, omega_num=None, + plot=None, margins=None, method='best', **kwargs): """Bode plot for a system. - Plots a Bode plot for the system over a (optional) frequency range. + Bode plot of a frequency response over a (optional) frequency range. Parameters ---------- - syslist : linsys - List of linear input/output systems (single system is OK) + data : list of `FrequencyResponseData` + List of :class:`FrequencyResponseData` objects. For backward + compatibility, a list of LTI systems can also be given. omega : array_like - List of frequencies in rad/sec to be used for frequency response + List of frequencies in rad/sec over to plot over. dB : bool - If True, plot result in dB. Default is false. + If True, plot result in dB. Default is False. Hz : bool If True, plot frequency in Hz (omega must be provided in rad/sec). - Default value (False) set by config.defaults['freqplot.Hz'] + Default value (False) set by config.defaults['freqplot.Hz']. deg : bool If True, plot phase in degrees (else radians). Default value (True) - config.defaults['freqplot.deg'] - plot : bool - If True (default), plot magnitude and phase - 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 - Number of samples to plot. Defaults to - config.defaults['freqplot.number_of_samples']. + set by config.defaults['freqplot.deg']. margins : bool If True, plot gain and phase margin. - method : method to use in computing margins (see :func:`stability_margins`) - *args : :func:`matplotlib.pyplot.plot` positional properties, optional - Additional arguments for `matplotlib` plots (color, linestyle, etc) + method : str, optional + Method to use in computing margins (see :func:`stability_margins`). + *fmt : :func:`matplotlib.pyplot.plot` format string, optional + Passed to `matplotlib` as the format string for all lines in the plot. **kwargs : :func:`matplotlib.pyplot.plot` keyword properties, optional - Additional keywords (passed to `matplotlib`) + Additional keywords passed to `matplotlib` to specify line properties. Returns ------- - mag : ndarray (or list of ndarray if len(syslist) > 1)) - magnitude - phase : ndarray (or list of ndarray if len(syslist) > 1)) - phase in radians - omega : ndarray (or list of ndarray if len(syslist) > 1)) - frequency in rad/sec + out : array of Line2D + Array of Line2D objects for each line in the plot. The shape of + the array matches the subplots shape and the value of the array is a + list of Line2D objects in that subplot. + mag : ndarray (or list of ndarray if len(data) > 1)) + If plot=False, magnitude of the respone (deprecated). + phase : ndarray (or list of ndarray if len(data) > 1)) + If plot=False, phase in radians of the respone (deprecated). + omega : ndarray (or list of ndarray if len(data) > 1)) + If plot=False, frequency in rad/sec (deprecated). Other Parameters ---------------- + plot : bool + If True (default), plot magnitude and phase. + 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 + Number of samples to plot. Defaults to + config.defaults['freqplot.number_of_samples']. grid : bool If True, plot grid lines on gain and phase plots. Default is set by `config.defaults['freqplot.grid']`. @@ -172,23 +203,25 @@ def bode_plot(syslist, omega=None, is the discrete timebase. If timebase not specified (``dt=True``), `dt` is set to 1. + 3. The legacy version of this function is invoked if instead of passing + frequency response data, a system (or list of systems) is passed as + the first argument, or if the (deprecated) keyword `plot` is set to + True or False. The return value is then given as `mag`, `phase`, + `omega` for the plotted frequency response (SISO only). + Examples -------- >>> G = ct.ss([[-1, -2], [3, -4]], [[5], [7]], [[6, 8]], [[9]]) >>> Gmag, Gphase, Gomega = ct.bode_plot(G) """ + # + # Process keywords and set defaults + # + # Make a copy of the kwargs dictionary since we will modify it kwargs = dict(kwargs) - # Check to see if legacy 'Plot' keyword was used - if 'Plot' in kwargs: - import warnings - warnings.warn("'Plot' keyword is deprecated in bode_plot; use 'plot'", - FutureWarning) - # Map 'Plot' keyword to 'plot' keyword - plot = kwargs.pop('Plot') - # Get values for params (and pop from list to allow keyword use in plot) dB = config._get_param( 'freqplot', 'dB', kwargs, _freqplot_defaults, pop=True) @@ -206,315 +239,516 @@ def bode_plot(syslist, omega=None, initial_phase = config._get_param( 'freqplot', 'initial_phase', kwargs, None, pop=True) omega_num = config._get_param('freqplot', 'number_of_samples', omega_num) + freqplot_rcParams = config._get_param( + 'freqplot', 'rcParams', kwargs, _freqplot_defaults, pop=True) + + if not isinstance(data, (list, tuple)): + data = [data] + + # For backwards compatibility, allow systems in the data list + if all([isinstance( + sys, (StateSpace, TransferFunction)) for sys in data]): + data = frequency_response( + data, omega=omega, omega_limits=omega_limits, + omega_num=omega_num) + warnings.warn( + "passing systems to `bode_plot` is deprecated; " + "use `frequency_response()`", DeprecationWarning) + if plot is None: + plot = True # Keep track of legacy usage (see notes below) - # If argument was a singleton, turn it into a tuple - if not isinstance(syslist, (list, tuple)): - syslist = (syslist,) - - omega, omega_range_given = _determine_omega_vector( - syslist, omega, omega_limits, omega_num, Hz=Hz) + # + # Process the data to be plotted + # + # To maintain compatibility with legacy uses of bode_plot(), we do some + # initial processing on the data, specifically phase unwrapping and + # setting the initial value of the phase. If bode_plot is called with + # plot == False, then these values are returned to the user (instead of + # the list of lines created, which is the new output for _plot functions. + # + # TODO: update to match timpelot inputs/outputs structure - 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.pop('fig') - ax_mag = fig.axes[0] - ax_phase = fig.axes[2] - sisotool = kwargs.pop('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: - if not sys.issiso(): + mags, phases, omegas, nyquistfrqs = [], [], [], [] # TODO: remove + for response in data: + if not response.issiso(): # TODO: Add MIMO bode plots. raise ControlMIMONotImplemented( "Bode is currently only implemented for SISO systems.") - else: - 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)) - else: - nyquistfrq = None - mag, phase, omega_sys = sys.frequency_response(omega_sys) - mag = np.atleast_1d(mag) - phase = np.atleast_1d(phase) + mag, phase, omega_sys = response + mag = np.atleast_1d(mag) + phase = np.atleast_1d(phase) + nyquistfrq = None if response.isctime() else math.pi / response.dt - # - # Post-process the phase to handle initial value and wrapping - # + ### + ### Code below can go into plotting section, but may need to + ### duplicate in frequency_response() ?? + ### - if initial_phase is None: - # Start phase in the range 0 to -360 w/ initial phase = -180 - # If wrap_phase is true, use 0 instead (phase \in (-pi, pi]) - initial_phase_value = -math.pi if wrap_phase is not True else 0 - elif isinstance(initial_phase, (int, float)): - # Allow the user to override the default calculation - if deg: - initial_phase_value = initial_phase/180. * math.pi - else: - initial_phase_value = initial_phase + # + # Post-process the phase to handle initial value and wrapping + # + if initial_phase is None: + # Start phase in the range 0 to -360 w/ initial phase = -180 + # If wrap_phase is true, use 0 instead (phase \in (-pi, pi]) + initial_phase_value = -math.pi if wrap_phase is not True else 0 + elif isinstance(initial_phase, (int, float)): + # Allow the user to override the default calculation + if deg: + initial_phase_value = initial_phase/180. * math.pi else: - raise ValueError("initial_phase must be a number.") - - # Shift the phase if needed - if abs(phase[0] - initial_phase_value) > math.pi: - phase -= 2*math.pi * \ - round((phase[0] - initial_phase_value) / (2*math.pi)) - - # Phase wrapping - if wrap_phase is False: - phase = unwrap(phase) # unwrap the phase - elif wrap_phase is True: - pass # default calculation OK - elif isinstance(wrap_phase, (int, float)): - phase = unwrap(phase) # unwrap the phase first - if deg: - wrap_phase *= math.pi/180. + initial_phase_value = initial_phase + + else: + raise ValueError("initial_phase must be a number.") + + # Shift the phase if needed + if abs(phase[0] - initial_phase_value) > math.pi: + phase -= 2*math.pi * \ + round((phase[0] - initial_phase_value) / (2*math.pi)) + + # Phase wrapping + if wrap_phase is False: + phase = unwrap(phase) # unwrap the phase + elif wrap_phase is True: + pass # default calculation OK + elif isinstance(wrap_phase, (int, float)): + phase = unwrap(phase) # unwrap the phase first + if deg: + wrap_phase *= math.pi/180. + + # Shift the phase if it is below the wrap_phase + phase += 2*math.pi * np.maximum( + 0, np.ceil((wrap_phase - phase)/(2*math.pi))) + else: + raise ValueError("wrap_phase must be bool or float.") + + mags.append(mag) + phases.append(phase) + omegas.append(omega_sys) + nyquistfrqs.append(nyquistfrq) + # Get the dimensions of the current axis, which we will divide up + # TODO: Not current implemented; just use subplot for now + + # + # Process `plot` keyword + # + # We use the `plot` keyword to track legacy usage of `bode_plot`. + # Prior to v0.10, the `bode_plot` command returned mag, phase, and + # omega. Post v0.10, we return an array with the same shape as the + # axes we use for plotting, with each array element containing a list + # of lines drawn on that axes. + # + # There are three possibilities at this stage in the code: + # + # * plot == True: either set explicitly by the user or we were passed a + # non-FRD system instead of data. Return mag, phase, omega, with a + # warning. + # + # * plot == False: set explicitly by the user. Return mag, phase, + # omega, with a warning. + # + # * plot == None: this is the new default setting and if it hasn't been + # changed, then we use the v0.10+ standard of returning an array of + # lines that were drawn. + # + # The one case that can cause problems is that a user called + # `bode_plot` with an FRD system, didn't set the plot keyword + # explicitly, and expected mag, phase, omega as a return value. This + # is hopefully a rare case (it wasn't in any of our unit tests nor + # examples at the time of v0.10.0). + # + # All of this should be removed in v0.11+ when we get rid of deprecated + # code. + # + + if plot is True or plot is False: + warnings.warn( + "`bode_plot` return values of mag, phase, omega is deprecated; " + "use frequency_response()", DeprecationWarning) - # Shift the phase if it is below the wrap_phase - phase += 2*math.pi * np.maximum( - 0, np.ceil((wrap_phase - phase)/(2*math.pi))) + if plot is False: + if len(data) == 1: + return mags[0], phases[0], omegas[0] + else: + return mags, phases, omegas + # + # Find/create axes + # + # Data are plotted in a standard subplots array, whose size depends on + # which signals are being plotted and how they are combined. The + # baseline layout for data is to plot everything separately, with + # the magnitude and phase for each output making up the rows and the + # columns corresponding to the different inputs. + # + # Input 0 Input m + # +---------------+ +---------------+ + # | mag H_y0,u0 | ... | mag H_y0,um | + # +---------------+ +---------------+ + # +---------------+ +---------------+ + # | phase H_y0,u0 | ... | phase H_y0,um | + # +---------------+ +---------------+ + # : : + # +---------------+ +---------------+ + # | mag H_yp,u0 | ... | mag H_yp,um | + # +---------------+ +---------------+ + # +---------------+ +---------------+ + # | phase H_yp,u0 | ... | phase H_yp,um | + # +---------------+ +---------------+ + # + # Several operations are available that change this layout. + # + # * Omitting: either the magnitude or the phase plots can be omitted + # using the plot_magnitude and plot_phase keywords. + # + # * Overlay: inputs and/or outputs can be combined onto a single set of + # axes using the overlay_inputs and overlay_outputs keywords. This + # basically collapses data along either the rows or columns, and a + # legend is generated. + # + + # Decide on the number of inputs and outputs + ninputs, noutputs = 0, 0 + for response in data: + ninputs += response.ninputs + noutputs += response.noutputs + ntraces = 1 # TODO: assume 1 trace per response for now + + # Figure how how many rows and columns to use + offsets for inputs/outputs + nrows = noutputs * 2 + ncols = ninputs + + # See if we can use the current figure axes + fig = plt.gcf() # get current figure (or create new one) + if ax is None and plt.get_fignums(): + ax = fig.get_axes() + if len(ax) == nrows * ncols: + # Assume that the shape is right (no easy way to infer this) + ax = np.array(ax).reshape(nrows, ncols) + elif len(ax) != 0 and 'sisotool' not in kwargs: # TODO: remove sisotool + # Need to generate a new figure + fig, ax = plt.figure(), None + else: + # Blank figure, just need to recreate axes + ax = None + + # Create new axes, if needed, and customize them + if ax is None and 'sisotool' not in kwargs: # TODO: remove sisotool + with plt.rc_context(_freqplot_rcParams): + ax_array = fig.subplots(nrows, ncols, sharex=True, squeeze=False) + fig.set_tight_layout(True) + fig.align_labels() + + elif 'sisotool' not in kwargs: # TODO: remove sisotool + # Make sure the axes are the right shape + if ax.shape != (nrows, ncols): + raise ValueError( + "specified axes are not the right shape; " + f"got {ax.shape} but expecting ({nrows}, {ncols})") + ax_array = ax + + # 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. + # + # TODO: rewrite this code to us subplot and the ax keyword to implement + # the same functionality. + + # Get the current figure + if 'sisotool' in kwargs: + fig = kwargs.pop('fig') # redo to use ax parameter + ax_mag = fig.axes[0] + ax_phase = fig.axes[2] + sisotool = kwargs.pop('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) + + # + # Plot the data + # + # The ax_magnitude and ax_phase arrays have the axes needed for making the + # plots. Labels are used on each axes for later creation of legends. + # The generic labels if of the form: + # + # To output label, From input label, system name + # + # The input and output labels are omitted if overlay_inputs or + # overlay_outputs is False, respectively. The system name is always + # included, since multiple calls to plot() will require a legend that + # distinguishes which system signals are plotted. The system name is + # stripped off later (in the legend-handling code) if it is not needed. + # + + for mag, phase, omega_sys, nyquistfrq in \ + zip(mags, phases, omegas, nyquistfrqs): + + nyquistfrq_plot = None + if Hz: + omega_plot = omega_sys / (2. * math.pi) + if nyquistfrq: + nyquistfrq_plot = nyquistfrq / (2. * math.pi) + else: + omega_plot = omega_sys + if nyquistfrq: + nyquistfrq_plot = nyquistfrq + phase_plot = phase * 180. / math.pi if deg else phase + mag_plot = mag + + if nyquistfrq_plot: + # append data for vertical nyquist freq indicator line. + # if this extra nyquist line 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_plot, nyquistfrq_plot)) + 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_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 + if dB: + ax_mag.semilogx(omega_plot, 20 * np.log10(mag_plot), + *fmt, **kwargs) + else: + ax_mag.loglog(omega_plot, mag_plot, *fmt, **kwargs) + + # Add a grid to the plot + labeling + ax_mag.grid(grid and not margins, which='both') + ax_mag.set_ylabel("Magnitude (dB)" if dB else "Magnitude") + + # Phase + ax_phase.semilogx(omega_plot, phase_plot, *fmt, **kwargs) + + # + # Plot gain and phase margins + # + + # Show the phase and gain margins in the plot + if margins: + # Compute stability margins for the system + margin = stability_margins(response, method=method) + 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) + phase_at_cp = phases[0][(np.abs(omegas[0] - Wcp)).argmin()] + if phase_at_cp >= 0.: + phase_limit = 180. else: - raise ValueError("wrap_phase must be bool or float.") - - mags.append(mag) - phases.append(phase) - omegas.append(omega_sys) - nyquistfrqs.append(nyquistfrq) - # Get the dimensions of the current axis, which we will divide up - # TODO: Not current implemented; just use subplot for now - - if plot: - nyquistfrq_plot = None - if Hz: - omega_plot = omega_sys / (2. * math.pi) - if nyquistfrq: - nyquistfrq_plot = nyquistfrq / (2. * math.pi) + phase_limit = -180. + + if Hz: + Wcg, Wcp = Wcg/(2*math.pi), Wcp/(2*math.pi) + + # Draw lines at gain and phase limits + ax_mag.axhline(y=0 if dB else 1, color='k', linestyle=':', + zorder=-20) + ax_phase.axhline(y=phase_limit if deg else + math.radians(phase_limit), + color='k', linestyle=':', zorder=-20) + mag_ylim = ax_mag.get_ylim() + phase_ylim = ax_phase.get_ylim() + + # Annotate the phase margin (if it exists) + if pm != float('inf') and Wcp != float('nan'): + if dB: + ax_mag.semilogx( + [Wcp, Wcp], [0., -1e5], + color='k', linestyle=':', zorder=-20) else: - omega_plot = omega_sys - if nyquistfrq: - nyquistfrq_plot = nyquistfrq - phase_plot = phase * 180. / math.pi if deg else phase - mag_plot = mag - - if nyquistfrq_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_plot, nyquistfrq_plot)) - 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_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 - # + ax_mag.loglog( + [Wcp, Wcp], [1., 1e-8], + color='k', linestyle=':', zorder=-20) + if deg: + ax_phase.semilogx( + [Wcp, Wcp], [1e5, phase_limit + pm], + color='k', linestyle=':', zorder=-20) + ax_phase.semilogx( + [Wcp, Wcp], [phase_limit + pm, phase_limit], + color='k', zorder=-20) + else: + ax_phase.semilogx( + [Wcp, Wcp], [1e5, math.radians(phase_limit) + + math.radians(pm)], + color='k', linestyle=':', zorder=-20) + ax_phase.semilogx( + [Wcp, Wcp], [math.radians(phase_limit) + + math.radians(pm), + math.radians(phase_limit)], + color='k', zorder=-20) + + # Annotate the gain margin (if it exists) + if gm != float('inf') and Wcg != float('nan'): if dB: - ax_mag.semilogx(omega_plot, 20 * np.log10(mag_plot), - *args, **kwargs) + ax_mag.semilogx( + [Wcg, Wcg], [-20.*np.log10(gm), -1e5], + color='k', linestyle=':', zorder=-20) + ax_mag.semilogx( + [Wcg, Wcg], [0, -20*np.log10(gm)], + color='k', zorder=-20) else: - 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') - ax_mag.set_ylabel("Magnitude (dB)" if dB else "Magnitude") - - # - # Phase plot - # - - # Plot the data - ax_phase.semilogx(omega_plot, phase_plot, *args, **kwargs) - - # Show the phase and gain margins in the plot - if margins: - # Compute stability margins for the system - margin = stability_margins(sys, method=method) - 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) - phase_at_cp = phases[0][(np.abs(omegas[0] - Wcp)).argmin()] - if phase_at_cp >= 0.: - phase_limit = 180. - else: - phase_limit = -180. - - if Hz: - Wcg, Wcp = Wcg/(2*math.pi), Wcp/(2*math.pi) - - # Draw lines at gain and phase limits - ax_mag.axhline(y=0 if dB else 1, color='k', linestyle=':', - zorder=-20) - ax_phase.axhline(y=phase_limit if deg else - math.radians(phase_limit), - color='k', linestyle=':', zorder=-20) - mag_ylim = ax_mag.get_ylim() - phase_ylim = ax_phase.get_ylim() - - # Annotate the phase margin (if it exists) - if pm != float('inf') and Wcp != float('nan'): - if dB: - ax_mag.semilogx( - [Wcp, Wcp], [0., -1e5], - color='k', linestyle=':', zorder=-20) - else: - ax_mag.loglog( - [Wcp, Wcp], [1., 1e-8], - color='k', linestyle=':', zorder=-20) - - if deg: - ax_phase.semilogx( - [Wcp, Wcp], [1e5, phase_limit + pm], - color='k', linestyle=':', zorder=-20) - ax_phase.semilogx( - [Wcp, Wcp], [phase_limit + pm, phase_limit], - color='k', zorder=-20) - else: - ax_phase.semilogx( - [Wcp, Wcp], [1e5, math.radians(phase_limit) + - math.radians(pm)], - color='k', linestyle=':', zorder=-20) - ax_phase.semilogx( - [Wcp, Wcp], [math.radians(phase_limit) + - math.radians(pm), - math.radians(phase_limit)], - color='k', zorder=-20) - - # Annotate the gain margin (if it exists) - if gm != float('inf') and Wcg != float('nan'): - if dB: - ax_mag.semilogx( - [Wcg, Wcg], [-20.*np.log10(gm), -1e5], - color='k', linestyle=':', zorder=-20) - ax_mag.semilogx( - [Wcg, Wcg], [0, -20*np.log10(gm)], - color='k', zorder=-20) - else: - ax_mag.loglog( - [Wcg, Wcg], [1./gm, 1e-8], color='k', - linestyle=':', zorder=-20) - ax_mag.loglog( - [Wcg, Wcg], [1., 1./gm], color='k', zorder=-20) - - if deg: - ax_phase.semilogx( - [Wcg, Wcg], [0, phase_limit], - color='k', linestyle=':', zorder=-20) - else: - ax_phase.semilogx( - [Wcg, Wcg], [0, math.radians(phase_limit)], - color='k', linestyle=':', zorder=-20) - - ax_mag.set_ylim(mag_ylim) - ax_phase.set_ylim(phase_ylim) - - if sisotool: - ax_mag.text( - 0.04, 0.06, - 'G.M.: %.2f %s\nFreq: %.2f %s' % - (20*np.log10(gm) if dB else gm, - 'dB ' if dB else '', - Wcg, 'Hz' if Hz else 'rad/s'), - horizontalalignment='left', - verticalalignment='bottom', - transform=ax_mag.transAxes, - fontsize=8 if int(mpl.__version__[0]) == 1 else 6) - ax_phase.text( - 0.04, 0.06, - 'P.M.: %.2f %s\nFreq: %.2f %s' % - (pm if deg else math.radians(pm), - 'deg' if deg else 'rad', - Wcp, 'Hz' if Hz else 'rad/s'), - horizontalalignment='left', - verticalalignment='bottom', - transform=ax_phase.transAxes, - fontsize=8 if int(mpl.__version__[0]) == 1 else 6) - else: - plt.suptitle( - "Gm = %.2f %s(at %.2f %s), " - "Pm = %.2f %s (at %.2f %s)" % - (20*np.log10(gm) if dB else gm, - 'dB ' if dB else '', - Wcg, 'Hz' if Hz else 'rad/s', - pm if deg else math.radians(pm), - 'deg' if deg else 'rad', - Wcp, 'Hz' if Hz else 'rad/s')) - - # Add a grid to the plot + labeling - ax_phase.set_ylabel("Phase (deg)" if deg else "Phase (rad)") - - def gen_zero_centered_series(val_min, val_max, period): - v1 = np.ceil(val_min / period - 0.2) - v2 = np.floor(val_max / period + 0.2) - return np.arange(v1, v2 + 1) * period + ax_mag.loglog( + [Wcg, Wcg], [1./gm, 1e-8], color='k', + linestyle=':', zorder=-20) + ax_mag.loglog( + [Wcg, Wcg], [1., 1./gm], color='k', zorder=-20) + if deg: - ylim = ax_phase.get_ylim() - ax_phase.set_yticks(gen_zero_centered_series( - ylim[0], ylim[1], 45.)) - ax_phase.set_yticks(gen_zero_centered_series( - ylim[0], ylim[1], 15.), minor=True) + ax_phase.semilogx( + [Wcg, Wcg], [0, phase_limit], + color='k', linestyle=':', zorder=-20) else: - ylim = ax_phase.get_ylim() - ax_phase.set_yticks(gen_zero_centered_series( - ylim[0], ylim[1], math.pi / 4.)) - ax_phase.set_yticks(gen_zero_centered_series( - ylim[0], ylim[1], math.pi / 12.), minor=True) - ax_phase.grid(grid and not margins, which='both') - # ax_mag.grid(which='minor', alpha=0.3) - # ax_mag.grid(which='major', alpha=0.9) - # ax_phase.grid(which='minor', alpha=0.3) - # ax_phase.grid(which='major', alpha=0.9) - - # Label the frequency axis - ax_phase.set_xlabel("Frequency (Hz)" if Hz - else "Frequency (rad/sec)") + ax_phase.semilogx( + [Wcg, Wcg], [0, math.radians(phase_limit)], + color='k', linestyle=':', zorder=-20) + + ax_mag.set_ylim(mag_ylim) + ax_phase.set_ylim(phase_ylim) + + if sisotool: + ax_mag.text( + 0.04, 0.06, + 'G.M.: %.2f %s\nFreq: %.2f %s' % + (20*np.log10(gm) if dB else gm, + 'dB ' if dB else '', + Wcg, 'Hz' if Hz else 'rad/s'), + horizontalalignment='left', + verticalalignment='bottom', + transform=ax_mag.transAxes, + fontsize=8 if int(mpl.__version__[0]) == 1 else 6) + ax_phase.text( + 0.04, 0.06, + 'P.M.: %.2f %s\nFreq: %.2f %s' % + (pm if deg else math.radians(pm), + 'deg' if deg else 'rad', + Wcp, 'Hz' if Hz else 'rad/s'), + horizontalalignment='left', + verticalalignment='bottom', + transform=ax_phase.transAxes, + fontsize=8 if int(mpl.__version__[0]) == 1 else 6) + else: + plt.suptitle( + "Gm = %.2f %s(at %.2f %s), " + "Pm = %.2f %s (at %.2f %s)" % + (20*np.log10(gm) if dB else gm, + 'dB ' if dB else '', + Wcg, 'Hz' if Hz else 'rad/s', + pm if deg else math.radians(pm), + 'deg' if deg else 'rad', + Wcp, 'Hz' if Hz else 'rad/s')) + + # Add a grid to the plot + labeling + ax_phase.set_ylabel("Phase (deg)" if deg else "Phase (rad)") + + def gen_zero_centered_series(val_min, val_max, period): + v1 = np.ceil(val_min / period - 0.2) + v2 = np.floor(val_max / period + 0.2) + return np.arange(v1, v2 + 1) * period + if deg: + ylim = ax_phase.get_ylim() + ax_phase.set_yticks(gen_zero_centered_series( + ylim[0], ylim[1], 45.)) + ax_phase.set_yticks(gen_zero_centered_series( + ylim[0], ylim[1], 15.), minor=True) + else: + ylim = ax_phase.get_ylim() + ax_phase.set_yticks(gen_zero_centered_series( + ylim[0], ylim[1], math.pi / 4.)) + ax_phase.set_yticks(gen_zero_centered_series( + ylim[0], ylim[1], math.pi / 12.), minor=True) + ax_phase.grid(grid and not margins, which='both') + # ax_mag.grid(which='minor', alpha=0.3) + # ax_mag.grid(which='major', alpha=0.9) + # ax_phase.grid(which='minor', alpha=0.3) + # ax_phase.grid(which='major', alpha=0.9) + + # Label the frequency axis + ax_phase.set_xlabel("Frequency (Hz)" if Hz + else "Frequency (rad/sec)") - if len(syslist) == 1: - return mags[0], phases[0], omegas[0] - else: - return mags, phases, omegas + # + # Label the axes (including trace labels) + # + # Once the data are plotted, we label the axes. The horizontal axes is + # always frequency and this is labeled only on the bottom most row. The + # vertical axes can consist either of a single signal or a combination + # of signals (when overlay_inputs or overlay_outputs is True) + # + # Input/output signals are give at the top of columns and left of rows + # when these are individually plotted. + # + + pass + + # + # Create legends + # + # Legends can be placed manually by passing a legend_map array that + # matches the shape of the suplots, with each item being a string + # indicating the location of the legend for that axes (or None for no + # legend). + # + # If no legend spec is passed, a minimal number of legends are used so + # that each line in each axis can be uniquely identified. The details + # depends on the various plotting parameters, but the general rule is + # to place legends in the top row and right column. + # + # Because plots can be built up by multiple calls to plot(), the legend + # strings are created from the line labels manually. Thus an initial + # call to plot() may not generate any legends (eg, if no signals are + # overlaid), but subsequent calls to plot() will need a legend for each + # different response (system). + # + + pass + + # + # Update the plot title (= figure suptitle) + # + # If plots are built up by multiple calls to plot() and the title is + # not given, then the title is updated to provide a list of unique text + # items in each successive title. For data generated by the frequency + # response function this will generate a common prefix followed by a + # list of systems (e.g., "Step response for sys[1], sys[2]"). + # + + pass + + if plot is True: # legacy usage; remove in future release + if len(data) == 1: + return mags[0], phases[0], omegas[0] + else: + return mags, phases, omegas + + return None # TODO: replace with ax # diff --git a/control/lti.py b/control/lti.py index efbc7c15b..4db2df128 100644 --- a/control/lti.py +++ b/control/lti.py @@ -5,6 +5,7 @@ """ import numpy as np +import math from numpy import real, angle, abs from warnings import warn @@ -56,16 +57,16 @@ def damp(self): zeta = -real(splane_poles)/wn return wn, zeta, poles - def frequency_response(self, omega, squeeze=None): + def frequency_response(self, omega=None, squeeze=None): """Evaluate the linear time-invariant system at an array of angular frequencies. - Reports the frequency response of the system, + For continuous time systems, computes the frequency response as G(j*omega) = mag * exp(j*phase) - for continuous time systems. For discrete time systems, the response - is evaluated around the unit circle such that + For discrete time systems, the response is evaluated around the + unit circle such that G(exp(j*omega*dt)) = mag * exp(j*phase). @@ -87,23 +88,25 @@ def frequency_response(self, omega, squeeze=None): Returns ------- - response : :class:`FrequencyReponseData` + response : :class:`FrequencyResponseData` Frequency response data object representing the frequency response. This object can be assigned to a tuple using mag, phase, omega = response - where ``mag`` is the magnitude (absolute value, not dB or - log10) of the system frequency response, ``phase`` is the wrapped - phase in radians of the system frequency response, and ``omega`` - is the (sorted) frequencies at which the response was evaluated. + where ``mag`` is the magnitude (absolute value, not dB or log10) + of the system frequency response, ``phase`` is the wrapped phase + in radians of the system frequency response, and ``omega`` is + the (sorted) frequencies at which the response was evaluated. If the system is SISO and squeeze is not True, ``magnitude`` and - ``phase`` are 1D, indexed by frequency. If the system is not SISO - or squeeze is False, the array is 3D, indexed by the output, - input, and frequency. If ``squeeze`` is True then - single-dimensional axes are removed. + ``phase`` are 1D, indexed by frequency. If the system is not + SISO or squeeze is False, the array is 3D, indexed by the + output, input, and, if omega is array_like, frequency. If + ``squeeze`` is True then single-dimensional axes are removed. """ + from .frdata import FrequencyResponseData + omega = np.sort(np.array(omega, ndmin=1)) if self.isdtime(strict=True): # Convert the frequency to discrete time @@ -114,10 +117,9 @@ def frequency_response(self, omega, squeeze=None): s = 1j * omega # Return the data as a frequency response data object - from .frdata import FrequencyResponseData response = self(s) return FrequencyResponseData( - response, omega, return_magphase=True, squeeze=squeeze) + response, omega, return_magphase=True, squeeze=squeeze, dt=self.dt) def dcgain(self): """Return the zero-frequency gain""" @@ -368,7 +370,8 @@ def evalfr(sys, x, squeeze=None): return sys(x, squeeze=squeeze) -def frequency_response(sys, omega, squeeze=None): +def frequency_response( + sys, omega=None, omega_limits=None, omega_num=None, squeeze=None): """Frequency response of an LTI system at multiple angular frequencies. In general the system may be multiple input, multiple output (MIMO), where @@ -377,12 +380,13 @@ def frequency_response(sys, omega, squeeze=None): Parameters ---------- - sys: StateSpace or TransferFunction - Linear system - omega : float or 1D array_like + sys: LTI system or list of LTI systems + Linear system(s) for which frequency response is computed. + omega : float or 1D array_like, optional A list of frequencies in radians/sec at which the system should be - evaluated. The list can be either a python list or a numpy array - and will be sorted before evaluation. + evaluated. The list can be either a Python list or a numpy array + and will be sorted before evaluation. If None (default), a common + set of frequencies that works across all systems is computed. squeeze : bool, optional If squeeze=True, remove single-dimensional entries from the shape of the output even if the system is not SISO. If squeeze=False, keep all @@ -392,7 +396,7 @@ def frequency_response(sys, omega, squeeze=None): Returns ------- - response : FrequencyResponseData + response : :class:`FrequencyResponseData` Frequency response data object representing the frequency response. This object can be assigned to a tuple using @@ -402,12 +406,15 @@ def frequency_response(sys, omega, squeeze=None): the system frequency response, ``phase`` is the wrapped phase in radians of the system frequency response, and ``omega`` is the (sorted) frequencies at which the response was evaluated. If the - system is SISO and squeeze is not True, ``magnitude`` and ``phase`` + system is SISO and squeeze is not False, ``magnitude`` and ``phase`` are 1D, indexed by frequency. If the system is not SISO or squeeze is False, the array is 3D, indexed by the output, input, and frequency. If ``squeeze`` is True then single-dimensional axes are removed. + Returns a list of :class:`FrequencyResponseData` objects if sys is + a list of systems. + See Also -------- evalfr @@ -438,11 +445,37 @@ def frequency_response(sys, omega, squeeze=None): #>>> # s = 0.1i, i, 10i. """ - return sys.frequency_response(omega, squeeze=squeeze) - + from .freqplot import _determine_omega_vector + + # Convert the first argument to a list + syslist = sys if isinstance(sys, (list, tuple)) else [sys] + + # Get the common set of frequencies to use + omega_syslist, omega_range_given = _determine_omega_vector( + syslist, omega, omega_limits, omega_num) + + responses = [] + for sys_ in syslist: + # Add the Nyquist frequency for discrete time systems + omega_sys = omega_syslist.copy() + if sys_.isdtime(strict=True): + nyquistfrq = math.pi / sys_.dt + if not omega_range_given: + # limit up to and including nyquist frequency + # TODO: make this optional? + omega_sys = np.hstack(( + omega_sys[omega_sys < nyquistfrq], nyquistfrq)) + + # Compute the frequency response + responses.append(sys_.frequency_response(omega_sys, squeeze=squeeze)) + + return responses if isinstance(sys, (list, tuple)) else responses[0] # Alternative name (legacy) -freqresp = frequency_response +def freqresp(sys, omega): + """Legacy version of frequency_response.""" + warn("freqresp is deprecated; use frequency_response", DeprecationWarning) + return frequency_response(sys, omega) def dcgain(sys): diff --git a/control/sisotool.py b/control/sisotool.py index 0ba94d498..0e43f6ef4 100644 --- a/control/sisotool.py +++ b/control/sisotool.py @@ -4,6 +4,7 @@ from .freqplot import bode_plot from .timeresp import step_response from .iosys import common_timebase, isctime, isdtime +from .lti import frequency_response from .xferfcn import tf from .statesp import ss, summing_junction from .bdalg import append, connect @@ -146,7 +147,7 @@ def _SisotoolUpdate(sys, fig, K, bode_plot_params, tvect=None): sys_loop = sys if sys.issiso() else sys[0,0] # Update the bodeplot - bode_plot_params['syslist'] = sys_loop*K.real + bode_plot_params['data'] = frequency_response(sys_loop*K.real) bode_plot(**bode_plot_params) # Set the titles and labels diff --git a/control/timeplot.py b/control/timeplot.py index 6409a6660..b6966fa16 100644 --- a/control/timeplot.py +++ b/control/timeplot.py @@ -220,7 +220,7 @@ def time_response_plot( # # * Omitting: either the inputs or the outputs can be omitted. # - # * Combining: inputs, outputs, and traces can be combined onto a + # * Overlay: inputs, outputs, and traces can be combined onto a # single set of axes using various keyword combinations # (overlay_signals, overlay_traces, plot_inputs='overlay'). This # basically collapses data along either the rows or columns, and a @@ -340,11 +340,11 @@ def time_response_plot( # # The ax_output and ax_input arrays have the axes needed for making the # plots. Labels are used on each axes for later creation of legends. - # The gneric labels if of the form: + # The generic labels if of the form: # # signal name, trace label, system name # - # The signal name or tracel label can be omitted if they will appear on + # The signal name or trace label can be omitted if they will appear on # the axes title or ylabel. The system name is always included, since # multiple calls to plot() will require a legend that distinguishes # which system signals are plotted. The system name is stripped off @@ -440,7 +440,7 @@ def _make_line_label(signal_index, signal_labels, trace_index): # Label the axes (including trace labels) # # Once the data are plotted, we label the axes. The horizontal axes is - # always time and this is labeled only on the bottom most column. The + # always time and this is labeled only on the bottom most row. The # vertical axes can consist either of a single signal or a combination # of signals (when overlay_signal is True or plot+inputs = 'overlay'. # From 530d689c430d731f433344fb24d69c6ca6903dda Mon Sep 17 00:00:00 2001 From: Richard Murray Date: Tue, 4 Jul 2023 07:08:18 -0700 Subject: [PATCH 02/17] update sisotool to use ax for bode_plot --- control/freqplot.py | 47 ++++++++++------------------------ control/sisotool.py | 8 +++--- control/tests/sisotool_test.py | 6 ++--- 3 files changed, 21 insertions(+), 40 deletions(-) diff --git a/control/freqplot.py b/control/freqplot.py index 6b8135ad6..6cd1afaef 100644 --- a/control/freqplot.py +++ b/control/freqplot.py @@ -118,7 +118,7 @@ def bode_plot( data, omega=None, *fmt, ax=None, omega_limits=None, omega_num=None, - plot=None, margins=None, method='best', **kwargs): + plot=None, margins=None, margin_info=False, method='best', **kwargs): """Bode plot for a system. Bode plot of a frequency response over a (optional) frequency range. @@ -139,7 +139,9 @@ def bode_plot( If True, plot phase in degrees (else radians). Default value (True) set by config.defaults['freqplot.deg']. margins : bool - If True, plot gain and phase margin. + If True, plot gain and phase margin. (TODO: merge with margin_info) + margin_info : bool + If True, plot information about gain and phase margin. method : str, optional Method to use in computing margins (see :func:`stability_margins`). *fmt : :func:`matplotlib.pyplot.plot` format string, optional @@ -410,9 +412,9 @@ def bode_plot( # Decide on the number of inputs and outputs ninputs, noutputs = 0, 0 - for response in data: - ninputs += response.ninputs - noutputs += response.noutputs + for response in data: # TODO: make more pythonic/numpic + ninputs = max(ninputs, response.ninputs) + noutputs = max(noutputs, response.noutputs) ntraces = 1 # TODO: assume 1 trace per response for now # Figure how how many rows and columns to use + offsets for inputs/outputs @@ -426,7 +428,7 @@ def bode_plot( if len(ax) == nrows * ncols: # Assume that the shape is right (no easy way to infer this) ax = np.array(ax).reshape(nrows, ncols) - elif len(ax) != 0 and 'sisotool' not in kwargs: # TODO: remove sisotool + elif len(ax) != 0: # Need to generate a new figure fig, ax = plt.figure(), None else: @@ -434,13 +436,13 @@ def bode_plot( ax = None # Create new axes, if needed, and customize them - if ax is None and 'sisotool' not in kwargs: # TODO: remove sisotool + if ax is None: with plt.rc_context(_freqplot_rcParams): ax_array = fig.subplots(nrows, ncols, sharex=True, squeeze=False) fig.set_tight_layout(True) fig.align_labels() - elif 'sisotool' not in kwargs: # TODO: remove sisotool + else: # Make sure the axes are the right shape if ax.shape != (nrows, ncols): raise ValueError( @@ -457,31 +459,8 @@ def bode_plot( # TODO: rewrite this code to us subplot and the ax keyword to implement # the same functionality. - # Get the current figure - if 'sisotool' in kwargs: - fig = kwargs.pop('fig') # redo to use ax parameter - ax_mag = fig.axes[0] - ax_phase = fig.axes[2] - sisotool = kwargs.pop('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) + ax_mag = ax_array[0, 0] + ax_phase = ax_array[1, 0] # # Plot the data @@ -633,7 +612,7 @@ def bode_plot( ax_mag.set_ylim(mag_ylim) ax_phase.set_ylim(phase_ylim) - if sisotool: + if margin_info: ax_mag.text( 0.04, 0.06, 'G.M.: %.2f %s\nFreq: %.2f %s' % diff --git a/control/sisotool.py b/control/sisotool.py index 0e43f6ef4..a66160cef 100644 --- a/control/sisotool.py +++ b/control/sisotool.py @@ -100,6 +100,8 @@ def sisotool(sys, initial_gain=None, xlim_rlocus=None, ylim_rlocus=None, plt.close(fig) fig,axes = plt.subplots(2, 2) fig.canvas.manager.set_window_title('Sisotool') + else: + axes = np.array(fig.get_axes()).reshape(2, 2) # Extract bode plot parameters bode_plot_params = { @@ -109,9 +111,9 @@ def sisotool(sys, initial_gain=None, xlim_rlocus=None, ylim_rlocus=None, 'deg': deg, 'omega_limits': omega_limits, 'omega_num' : omega_num, - 'sisotool': True, - 'fig': fig, - 'margins': margins_bode + 'ax': axes[:, 0:1], + 'margins': margins_bode, + 'margin_info': True, } # Check to see if legacy 'PrintGain' keyword was used diff --git a/control/tests/sisotool_test.py b/control/tests/sisotool_test.py index 2327440df..5a86c73d0 100644 --- a/control/tests/sisotool_test.py +++ b/control/tests/sisotool_test.py @@ -78,9 +78,9 @@ def test_sisotool(self, tsys): 'deg': True, 'omega_limits': None, 'omega_num': None, - 'sisotool': True, - 'fig': fig, - 'margins': True + 'ax': np.array([[ax_mag], [ax_phase]]), + 'margins': True, + 'margin_info': True, } # Check that the xaxes of the bode plot are shared before the rlocus click From c3ebbdac0d61f643ac65b7f103c2de967f02c329 Mon Sep 17 00:00:00 2001 From: Richard Murray Date: Tue, 4 Jul 2023 13:24:47 -0700 Subject: [PATCH 03/17] updated bode_plot titling + MIMO implementation --- control/frdata.py | 14 + control/freqplot.py | 612 ++++++++++++++++++++------------- control/lti.py | 3 +- control/tests/freqplot_test.py | 68 ++++ control/tests/freqresp_test.py | 5 +- control/tests/kwargs_test.py | 15 +- control/timeplot.py | 54 +-- 7 files changed, 504 insertions(+), 267 deletions(-) create mode 100644 control/tests/freqplot_test.py diff --git a/control/frdata.py b/control/frdata.py index 3aafa83db..f431966d1 100644 --- a/control/frdata.py +++ b/control/frdata.py @@ -78,6 +78,8 @@ class FrequencyResponseData(LTI): corresponding to the frequency points in omega w : iterable of real frequencies List of frequency points for which data are available. + sysname : str or None + Name of the system that generated the data. smooth : bool, optional If ``True``, create an interpolation function that allows the frequency response to be computed at any frequency within the range of @@ -204,6 +206,10 @@ def __init__(self, *args, **kwargs): # # Process key word arguments # + + # If data was generated by a system, keep track of that + self.sysname = kwargs.pop('sysname', None) + # Keep track of return type self.return_magphase=kwargs.pop('return_magphase', False) if self.return_magphase not in (True, False): @@ -638,6 +644,14 @@ def feedback(self, other=1, sign=-1): return FRD(fresp, other.omega, smooth=(self.ifunc is not None)) + # Plotting interface + def plot(self, *args, **kwargs): + from .freqplot import bode_plot + + # For now, only support Bode plots + # TODO: add 'kind' keyword and Nyquist plots (?) + bode_plot(self, *args, **kwargs) + # Convert to pandas def to_pandas(self): if not pandas_check(): diff --git a/control/freqplot.py b/control/freqplot.py index 6cd1afaef..fa698474b 100644 --- a/control/freqplot.py +++ b/control/freqplot.py @@ -53,22 +53,26 @@ # # $Id$ +# TODO: clean up imports import math +from os.path import commonprefix import matplotlib as mpl import matplotlib.pyplot as plt import numpy as np import warnings from math import nan +import itertools from .ctrlutil import unwrap from .bdalg import feedback from .margins import stability_margins from .exception import ControlMIMONotImplemented from .statesp import StateSpace -from .lti import frequency_response +from .lti import frequency_response, _process_frequency_response from .xferfcn import TransferFunction from .frdata import FrequencyResponseData +from .timeplot import _make_legend_labels from . import config __all__ = ['bode_plot', 'nyquist_plot', 'gangof4_plot', 'singular_values_plot', @@ -95,6 +99,7 @@ 'freqplot.Hz': False, # Plot frequency in Hertz 'freqplot.grid': True, # Turn on grid for gain and phase 'freqplot.wrap_phase': False, # Wrap the phase plot at a given value + 'freqplot.freq_label': "Frequency [%s]", # deprecations 'deprecated.bode.dB': 'freqplot.dB', @@ -118,7 +123,9 @@ def bode_plot( data, omega=None, *fmt, ax=None, omega_limits=None, omega_num=None, - plot=None, margins=None, margin_info=False, method='best', **kwargs): + plot=None, plot_magnitude=True, plot_phase=True, margins=None, + margin_info=False, method='best', legend_map=None, legend_loc=None, + title=None, relabel=True, **kwargs): """Bode plot for a system. Bode plot of a frequency response over a (optional) frequency range. @@ -243,6 +250,8 @@ def bode_plot( omega_num = config._get_param('freqplot', 'number_of_samples', omega_num) freqplot_rcParams = config._get_param( 'freqplot', 'rcParams', kwargs, _freqplot_defaults, pop=True) + freq_label = config._get_param( + 'freqplot', 'freq_label', kwargs, _freqplot_defaults, pop=True) if not isinstance(data, (list, tuple)): data = [data] @@ -270,16 +279,19 @@ def bode_plot( # # TODO: update to match timpelot inputs/outputs structure - mags, phases, omegas, nyquistfrqs = [], [], [], [] # TODO: remove - for response in data: - if not response.issiso(): - # TODO: Add MIMO bode plots. - raise ControlMIMONotImplemented( - "Bode is currently only implemented for SISO systems.") + if not plot_magnitude and not plot_phase: + raise ValueError( + "plot_magnitude and plot_phase both False; no data to plot") + mags, phases, omegas, nyquistfrqs = [], [], [], [] + sysnames = [] + for response in data: mag, phase, omega_sys = response mag = np.atleast_1d(mag) phase = np.atleast_1d(phase) + # mag, phase = response.magnitude, response.phase # TODO: use this + noutputs, ninputs = response.noutputs, response.ninputs + omega_sys = response.omega nyquistfrq = None if response.isctime() else math.pi / response.dt ### @@ -292,7 +304,8 @@ def bode_plot( # if initial_phase is None: - # Start phase in the range 0 to -360 w/ initial phase = -180 + # Start phase in the range 0 to -360 w/ initial phase = 0 + # TODO: change this to 0 to 270 (?) # If wrap_phase is true, use 0 instead (phase \in (-pi, pi]) initial_phase_value = -math.pi if wrap_phase is not True else 0 elif isinstance(initial_phase, (int, float)): @@ -305,33 +318,38 @@ def bode_plot( else: raise ValueError("initial_phase must be a number.") - # Shift the phase if needed - if abs(phase[0] - initial_phase_value) > math.pi: - phase -= 2*math.pi * \ - round((phase[0] - initial_phase_value) / (2*math.pi)) - - # Phase wrapping - if wrap_phase is False: - phase = unwrap(phase) # unwrap the phase - elif wrap_phase is True: - pass # default calculation OK - elif isinstance(wrap_phase, (int, float)): - phase = unwrap(phase) # unwrap the phase first - if deg: - wrap_phase *= math.pi/180. + # TODO: hack to make sure that SISO case still works properly + my_phase = phase.reshape((noutputs, ninputs, -1)) # TODO: remove + for i, j in itertools.product(range(noutputs), range(ninputs)): + # Shift the phase if needed + if abs(my_phase[i, j, 0] - initial_phase_value) > math.pi: + my_phase[i, j] -= 2*math.pi * round( + (my_phase[i, j, 0] - initial_phase_value) / (2*math.pi)) + + # Phase wrapping + if wrap_phase is False: + my_phase[i, j] = unwrap(my_phase[i, j]) # unwrap the phase + elif wrap_phase is True: + pass # default calc OK + elif isinstance(wrap_phase, (int, float)): + my_phase[i, j] = unwrap(my_phase[i, j]) # unwrap phase first + if deg: + wrap_phase *= math.pi/180. - # Shift the phase if it is below the wrap_phase - phase += 2*math.pi * np.maximum( - 0, np.ceil((wrap_phase - phase)/(2*math.pi))) - else: - raise ValueError("wrap_phase must be bool or float.") + # Shift the phase if it is below the wrap_phase + my_phase[i, j] += 2*math.pi * np.maximum( + 0, np.ceil((wrap_phase - my_phase[i, j])/(2*math.pi))) + else: + raise ValueError("wrap_phase must be bool or float.") + phase = my_phase.reshape(phase.shape) # TODO: remove mags.append(mag) phases.append(phase) omegas.append(omega_sys) nyquistfrqs.append(nyquistfrq) - # Get the dimensions of the current axis, which we will divide up - # TODO: Not current implemented; just use subplot for now + + # Save the system names for later + sysnames.append(response.sysname) # # Process `plot` keyword @@ -418,7 +436,8 @@ def bode_plot( ntraces = 1 # TODO: assume 1 trace per response for now # Figure how how many rows and columns to use + offsets for inputs/outputs - nrows = noutputs * 2 + nrows = (noutputs if plot_magnitude else 0) + \ + (noutputs if plot_phase else 0) ncols = ninputs # See if we can use the current figure axes @@ -435,10 +454,16 @@ def bode_plot( # Blank figure, just need to recreate axes ax = None + # Clear out any old text from the current figure + for text in fig.texts: + text.set_visible(False) # turn off the text + del text # get rid of it completely + # Create new axes, if needed, and customize them if ax is None: with plt.rc_context(_freqplot_rcParams): - ax_array = fig.subplots(nrows, ncols, sharex=True, squeeze=False) + ax_array = fig.subplots( + nrows, ncols, sharex='col', sharey='row', squeeze=False) fig.set_tight_layout(True) fig.align_labels() @@ -450,18 +475,6 @@ def bode_plot( f"got {ax.shape} but expecting ({nrows}, {ncols})") ax_array = ax - # 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. - # - # TODO: rewrite this code to us subplot and the ax keyword to implement - # the same functionality. - - ax_mag = ax_array[0, 0] - ax_phase = ax_array[1, 0] - # # Plot the data # @@ -478,200 +491,259 @@ def bode_plot( # stripped off later (in the legend-handling code) if it is not needed. # - for mag, phase, omega_sys, nyquistfrq in \ - zip(mags, phases, omegas, nyquistfrqs): - - nyquistfrq_plot = None - if Hz: - omega_plot = omega_sys / (2. * math.pi) - if nyquistfrq: - nyquistfrq_plot = nyquistfrq / (2. * math.pi) - else: - omega_plot = omega_sys - if nyquistfrq: - nyquistfrq_plot = nyquistfrq - phase_plot = phase * 180. / math.pi if deg else phase - mag_plot = mag - - if nyquistfrq_plot: - # append data for vertical nyquist freq indicator line. - # if this extra nyquist line 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_plot, nyquistfrq_plot)) - 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_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 - if dB: - ax_mag.semilogx(omega_plot, 20 * np.log10(mag_plot), - *fmt, **kwargs) - else: - ax_mag.loglog(omega_plot, mag_plot, *fmt, **kwargs) - - # Add a grid to the plot + labeling - ax_mag.grid(grid and not margins, which='both') - ax_mag.set_ylabel("Magnitude (dB)" if dB else "Magnitude") + for mag, phase, omega_sys, nyquistfrq, sysname in \ + zip(mags, phases, omegas, nyquistfrqs, sysnames): - # Phase - ax_phase.semilogx(omega_plot, phase_plot, *fmt, **kwargs) - - # - # Plot gain and phase margins - # + # TODO: hack to handle MIMO while not breaking SISO + my_mag = mag.reshape((noutputs, ninputs, -1)) + my_phase = phase.reshape((noutputs, ninputs, -1)) + for i, j in itertools.product( + range(my_mag.shape[0]), range(my_mag.shape[1])): + if plot_magnitude: + ax_mag = ax_array[i*2 if plot_phase else i, j] + if plot_phase: + ax_phase = ax_array[i*2 + 1 if plot_magnitude else i, j] - # Show the phase and gain margins in the plot - if margins: - # Compute stability margins for the system - margin = stability_margins(response, method=method) - 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) - phase_at_cp = phases[0][(np.abs(omegas[0] - Wcp)).argmin()] - if phase_at_cp >= 0.: - phase_limit = 180. + nyquistfrq_plot = None + if Hz: + omega_plot = omega_sys / (2. * math.pi) + if nyquistfrq: + nyquistfrq_plot = nyquistfrq / (2. * math.pi) else: - phase_limit = -180. + omega_plot = omega_sys + if nyquistfrq: + nyquistfrq_plot = nyquistfrq - if Hz: - Wcg, Wcp = Wcg/(2*math.pi), Wcp/(2*math.pi) - - # Draw lines at gain and phase limits - ax_mag.axhline(y=0 if dB else 1, color='k', linestyle=':', - zorder=-20) - ax_phase.axhline(y=phase_limit if deg else - math.radians(phase_limit), - color='k', linestyle=':', zorder=-20) - mag_ylim = ax_mag.get_ylim() - phase_ylim = ax_phase.get_ylim() - - # Annotate the phase margin (if it exists) - if pm != float('inf') and Wcp != float('nan'): + phase_plot = my_phase[i, j] * 180. / math.pi if deg \ + else my_phase[i, j] + mag_plot = my_mag[i, j] + + if nyquistfrq_plot: + # append data for vertical nyquist freq indicator line. + # if this extra nyquist line 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_plot, nyquistfrq_plot)) + 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_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 + if plot_magnitude: if dB: ax_mag.semilogx( - [Wcp, Wcp], [0., -1e5], - color='k', linestyle=':', zorder=-20) + omega_plot, 20 * np.log10(mag_plot), *fmt, + label=sysname, **kwargs) else: ax_mag.loglog( - [Wcp, Wcp], [1., 1e-8], - color='k', linestyle=':', zorder=-20) + omega_plot, mag_plot, *fmt, label=sysname, **kwargs) - if deg: - ax_phase.semilogx( - [Wcp, Wcp], [1e5, phase_limit + pm], - color='k', linestyle=':', zorder=-20) - ax_phase.semilogx( - [Wcp, Wcp], [phase_limit + pm, phase_limit], - color='k', zorder=-20) + # Add a grid to the plot + labeling + ax_mag.grid(grid and not margins, which='both') + + # Phase + if plot_phase: + ax_phase.semilogx( + omega_plot, phase_plot, *fmt, label=sysname, **kwargs) + + # + # Plot gain and phase margins + # + + # Show the phase and gain margins in the plot + if margins: + # Compute stability margins for the system + margin = stability_margins(response, method=method) + 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) + phase_at_cp = phases[0][(np.abs(omegas[0] - Wcp)).argmin()] + if phase_at_cp >= 0.: + phase_limit = 180. else: - ax_phase.semilogx( - [Wcp, Wcp], [1e5, math.radians(phase_limit) + - math.radians(pm)], - color='k', linestyle=':', zorder=-20) - ax_phase.semilogx( - [Wcp, Wcp], [math.radians(phase_limit) + - math.radians(pm), - math.radians(phase_limit)], - color='k', zorder=-20) - - # Annotate the gain margin (if it exists) - if gm != float('inf') and Wcg != float('nan'): - if dB: - ax_mag.semilogx( - [Wcg, Wcg], [-20.*np.log10(gm), -1e5], - color='k', linestyle=':', zorder=-20) - ax_mag.semilogx( - [Wcg, Wcg], [0, -20*np.log10(gm)], - color='k', zorder=-20) + phase_limit = -180. + + if Hz: + Wcg, Wcp = Wcg/(2*math.pi), Wcp/(2*math.pi) + + # Draw lines at gain and phase limits + if plot_magnitude: + ax_mag.axhline(y=0 if dB else 1, color='k', linestyle=':', + zorder=-20) + mag_ylim = ax_mag.get_ylim() + + if plot_phase: + ax_phase.axhline(y=phase_limit if deg else + math.radians(phase_limit), + color='k', linestyle=':', zorder=-20) + phase_ylim = ax_phase.get_ylim() + + # Annotate the phase margin (if it exists) + if plot_phase and pm != float('inf') and Wcp != float('nan'): + if dB: + ax_mag.semilogx( + [Wcp, Wcp], [0., -1e5], + color='k', linestyle=':', zorder=-20) + else: + ax_mag.loglog( + [Wcp, Wcp], [1., 1e-8], + color='k', linestyle=':', zorder=-20) + + if deg: + ax_phase.semilogx( + [Wcp, Wcp], [1e5, phase_limit + pm], + color='k', linestyle=':', zorder=-20) + ax_phase.semilogx( + [Wcp, Wcp], [phase_limit + pm, phase_limit], + color='k', zorder=-20) + else: + ax_phase.semilogx( + [Wcp, Wcp], [1e5, math.radians(phase_limit) + + math.radians(pm)], + color='k', linestyle=':', zorder=-20) + ax_phase.semilogx( + [Wcp, Wcp], [math.radians(phase_limit) + + math.radians(pm), + math.radians(phase_limit)], + color='k', zorder=-20) + + ax_phase.set_ylim(phase_ylim) + + # Annotate the gain margin (if it exists) + if plot_magnitude and gm != float('inf') and \ + Wcg != float('nan'): + if dB: + ax_mag.semilogx( + [Wcg, Wcg], [-20.*np.log10(gm), -1e5], + color='k', linestyle=':', zorder=-20) + ax_mag.semilogx( + [Wcg, Wcg], [0, -20*np.log10(gm)], + color='k', zorder=-20) + else: + ax_mag.loglog( + [Wcg, Wcg], [1./gm, 1e-8], color='k', + linestyle=':', zorder=-20) + ax_mag.loglog( + [Wcg, Wcg], [1., 1./gm], color='k', zorder=-20) + + if plot_phase: + if deg: + ax_phase.semilogx( + [Wcg, Wcg], [0, phase_limit], + color='k', linestyle=':', zorder=-20) + else: + ax_phase.semilogx( + [Wcg, Wcg], [0, math.radians(phase_limit)], + color='k', linestyle=':', zorder=-20) + + ax_mag.set_ylim(mag_ylim) + ax_phase.set_ylim(phase_ylim) + + if margin_info: + if plot_magnitude: + ax_mag.text( + 0.04, 0.06, + 'G.M.: %.2f %s\nFreq: %.2f %s' % + (20*np.log10(gm) if dB else gm, + 'dB ' if dB else '', + Wcg, 'Hz' if Hz else 'rad/s'), + horizontalalignment='left', + verticalalignment='bottom', + transform=ax_mag.transAxes, + fontsize=8 if int(mpl.__version__[0]) == 1 else 6) + if plot_phase: + ax_phase.text( + 0.04, 0.06, + 'P.M.: %.2f %s\nFreq: %.2f %s' % + (pm if deg else math.radians(pm), + 'deg' if deg else 'rad', + Wcp, 'Hz' if Hz else 'rad/s'), + horizontalalignment='left', + verticalalignment='bottom', + transform=ax_phase.transAxes, + fontsize=8 if int(mpl.__version__[0]) == 1 else 6) else: - ax_mag.loglog( - [Wcg, Wcg], [1./gm, 1e-8], color='k', - linestyle=':', zorder=-20) - ax_mag.loglog( - [Wcg, Wcg], [1., 1./gm], color='k', zorder=-20) - + # TODO: gets overwritten below + plt.suptitle( + "Gm = %.2f %s(at %.2f %s), " + "Pm = %.2f %s (at %.2f %s)" % + (20*np.log10(gm) if dB else gm, + 'dB ' if dB else '', + Wcg, 'Hz' if Hz else 'rad/s', + pm if deg else math.radians(pm), + 'deg' if deg else 'rad', + Wcp, 'Hz' if Hz else 'rad/s')) + + def gen_zero_centered_series(val_min, val_max, period): + v1 = np.ceil(val_min / period - 0.2) + v2 = np.floor(val_max / period + 0.2) + return np.arange(v1, v2 + 1) * period + + # TODO: what is going on here + # TODO: fix to use less dense labels, when needed + if plot_phase: if deg: - ax_phase.semilogx( - [Wcg, Wcg], [0, phase_limit], - color='k', linestyle=':', zorder=-20) + ylim = ax_phase.get_ylim() + ax_phase.set_yticks(gen_zero_centered_series( + ylim[0], ylim[1], 45.)) + ax_phase.set_yticks(gen_zero_centered_series( + ylim[0], ylim[1], 15.), minor=True) else: - ax_phase.semilogx( - [Wcg, Wcg], [0, math.radians(phase_limit)], - color='k', linestyle=':', zorder=-20) - - ax_mag.set_ylim(mag_ylim) - ax_phase.set_ylim(phase_ylim) - - if margin_info: - ax_mag.text( - 0.04, 0.06, - 'G.M.: %.2f %s\nFreq: %.2f %s' % - (20*np.log10(gm) if dB else gm, - 'dB ' if dB else '', - Wcg, 'Hz' if Hz else 'rad/s'), - horizontalalignment='left', - verticalalignment='bottom', - transform=ax_mag.transAxes, - fontsize=8 if int(mpl.__version__[0]) == 1 else 6) - ax_phase.text( - 0.04, 0.06, - 'P.M.: %.2f %s\nFreq: %.2f %s' % - (pm if deg else math.radians(pm), - 'deg' if deg else 'rad', - Wcp, 'Hz' if Hz else 'rad/s'), - horizontalalignment='left', - verticalalignment='bottom', - transform=ax_phase.transAxes, - fontsize=8 if int(mpl.__version__[0]) == 1 else 6) - else: - plt.suptitle( - "Gm = %.2f %s(at %.2f %s), " - "Pm = %.2f %s (at %.2f %s)" % - (20*np.log10(gm) if dB else gm, - 'dB ' if dB else '', - Wcg, 'Hz' if Hz else 'rad/s', - pm if deg else math.radians(pm), - 'deg' if deg else 'rad', - Wcp, 'Hz' if Hz else 'rad/s')) - - # Add a grid to the plot + labeling - ax_phase.set_ylabel("Phase (deg)" if deg else "Phase (rad)") - - def gen_zero_centered_series(val_min, val_max, period): - v1 = np.ceil(val_min / period - 0.2) - v2 = np.floor(val_max / period + 0.2) - return np.arange(v1, v2 + 1) * period - if deg: - ylim = ax_phase.get_ylim() - ax_phase.set_yticks(gen_zero_centered_series( - ylim[0], ylim[1], 45.)) - ax_phase.set_yticks(gen_zero_centered_series( - ylim[0], ylim[1], 15.), minor=True) - else: - ylim = ax_phase.get_ylim() - ax_phase.set_yticks(gen_zero_centered_series( - ylim[0], ylim[1], math.pi / 4.)) - ax_phase.set_yticks(gen_zero_centered_series( - ylim[0], ylim[1], math.pi / 12.), minor=True) - ax_phase.grid(grid and not margins, which='both') - # ax_mag.grid(which='minor', alpha=0.3) - # ax_mag.grid(which='major', alpha=0.9) - # ax_phase.grid(which='minor', alpha=0.3) - # ax_phase.grid(which='major', alpha=0.9) + ylim = ax_phase.get_ylim() + ax_phase.set_yticks(gen_zero_centered_series( + ylim[0], ylim[1], math.pi / 4.)) + ax_phase.set_yticks(gen_zero_centered_series( + ylim[0], ylim[1], math.pi / 12.), minor=True) - # Label the frequency axis - ax_phase.set_xlabel("Frequency (Hz)" if Hz - else "Frequency (rad/sec)") + ax_phase.grid(grid and not margins, which='both') + + # + # Update the plot title (= figure suptitle) + # + # If plots are built up by multiple calls to plot() and the title is + # not given, then the title is updated to provide a list of unique text + # items in each successive title. For data generated by the frequency + # response function this will generate a common prefix followed by a + # list of systems (e.g., "Step response for sys[1], sys[2]"). + # + + # Set the initial title for the data + sysnames = list(set(sysnames)) # get rid of duplicates + if title is None: + title = "Bode plot for " + ", ".join(sysnames) + + if fig is not None and title is not None: + # Get the current title, if it exists + old_title = None if fig._suptitle is None else fig._suptitle._text + new_title = title + + if old_title is not None: + # Find the common part of the titles + common_prefix = commonprefix([old_title, new_title]) + + # Back up to the last space + last_space = common_prefix.rfind(' ') + if last_space > 0: + common_prefix = common_prefix[:last_space] + common_len = len(common_prefix) + + # Add the new part of the title (usually the system name) + if old_title[common_len:] != new_title[common_len:]: + separator = ',' if len(common_prefix) > 0 else ';' + new_title = old_title + separator + new_title[common_len:] + + # Add the title + with plt.rc_context(freqplot_rcParams): + fig.suptitle(new_title) # # Label the axes (including trace labels) @@ -685,7 +757,62 @@ def gen_zero_centered_series(val_min, val_max, period): # when these are individually plotted. # - pass + # Label the columns (do this first to get row labels in the right spot) + for j in range(ninputs): + # If we have more than one column, label the individual responses + if noutputs > 1 or ninputs > 1: + with plt.rc_context(_freqplot_rcParams): + ax_array[0, j].set_title(f"From {data[0].input_labels[j]}") + + # Label the frequency axis + ax_array[-1, j].set_xlabel(freq_label % ("Hz" if Hz else "rad/s",)) + + # Label the rows + for i in range(noutputs): + if plot_magnitude: + ax_mag = ax_array[i*2 if plot_phase else i, 0] + ax_mag.set_ylabel("Magnitude (dB)" if dB else "Magnitude") + if plot_phase: + ax_phase = ax_array[i*2 + 1 if plot_magnitude else i, 0] + ax_phase.set_ylabel("Phase (deg)" if deg else "Phase (rad)") + + if noutputs > 1 or ninputs > 1: + if plot_magnitude and plot_phase: + # Get existing ylabel for left column and add a blank line + ax_mag.set_ylabel("\n" + ax_mag.get_ylabel()) + ax_phase.set_ylabel("\n" + ax_phase.get_ylabel()) + + # TODO: remove? + # Redraw the figure to get the proper locations for everything + # fig.tight_layout() + + # Get the bounding box including the labels + inv_transform = fig.transFigure.inverted() + mag_bbox = inv_transform.transform( + ax_mag.get_tightbbox(fig.canvas.get_renderer())) + phase_bbox = inv_transform.transform( + ax_phase.get_tightbbox(fig.canvas.get_renderer())) + + # Get the axes limits without labels for use in the y position + mag_bot = inv_transform.transform( + ax_mag.transAxes.transform((0, 0)))[1] + phase_top = inv_transform.transform( + ax_phase.transAxes.transform((0, 1)))[1] + + # Figure out location for the text (center left in figure frame) + xpos = mag_bbox[0, 0] # left edge + ypos = (mag_bot + phase_top) / 2 # centered between axes + + # Put a centered label as text outside the box + fig.text( + 0.8 * xpos, ypos, f"To {data[0].output_labels[i]}\n", + rotation=90, ha='left', va='center', + fontsize=_freqplot_rcParams['axes.titlesize']) + else: + # Only a single axes => add label to the left + ax_array[i, 0].set_ylabel( + f"To {data[0].output_labels[i]}\n" + + ax_array[i, 0].get_ylabel()) # # Create legends @@ -707,20 +834,33 @@ def gen_zero_centered_series(val_min, val_max, period): # different response (system). # - pass + # Figure out where to put legends + if legend_map is None: + legend_map = np.full(ax_array.shape, None, dtype=object) + if legend_loc == None: + legend_loc = 'center right' - # - # Update the plot title (= figure suptitle) - # - # If plots are built up by multiple calls to plot() and the title is - # not given, then the title is updated to provide a list of unique text - # items in each successive title. For data generated by the frequency - # response function this will generate a common prefix followed by a - # list of systems (e.g., "Step response for sys[1], sys[2]"). - # + # TODO: add in additional processing later + + # Put legend in the upper right + legend_map[0, -1] = legend_loc + + # Create axis legends + for i in range(nrows): + for j in range(ncols): + ax = ax_array[i, j] + # Get the labels to use, removing common strings + labels = _make_legend_labels( + [line.get_label() for line in ax.get_lines()]) - pass + # Generate the label, if needed + if len(labels) > 1 and legend_map[i, j] != None: + with plt.rc_context(freqplot_rcParams): + ax.legend(labels, loc=legend_map[i, j]) + # + # Legacy return pocessing + # if plot is True: # legacy usage; remove in future release if len(data) == 1: return mags[0], phases[0], omegas[0] diff --git a/control/lti.py b/control/lti.py index 4db2df128..62eabd69d 100644 --- a/control/lti.py +++ b/control/lti.py @@ -119,7 +119,8 @@ def frequency_response(self, omega=None, squeeze=None): # Return the data as a frequency response data object response = self(s) return FrequencyResponseData( - response, omega, return_magphase=True, squeeze=squeeze, dt=self.dt) + response, omega, return_magphase=True, squeeze=squeeze, dt=self.dt, + sysname=self.name) def dcgain(self): """Return the zero-frequency gain""" diff --git a/control/tests/freqplot_test.py b/control/tests/freqplot_test.py new file mode 100644 index 000000000..aa2fb9dc0 --- /dev/null +++ b/control/tests/freqplot_test.py @@ -0,0 +1,68 @@ +# freqplot_test.py - test out frequency response plots +# RMM, 23 Jun 2023 + +import pytest +import control as ct +import matplotlib as mpl +import matplotlib.pyplot as plt +import numpy as np + +from control.tests.conftest import slycotonly +pytestmark = pytest.mark.usefixtures("mplcleanup") + +def test_basic_freq_plots(savefigs=False): + # Basic SISO Bode plot + plt.figure() + # ct.frequency_response(sys_siso).plot() + sys1 = ct.tf([1], [1, 2, 1], name='System 1') + sys2 = ct.tf([1, 0.2], [1, 1, 3, 1, 1], name='System 2') + response = ct.frequency_response([sys1, sys2]) + ct.bode_plot(response) + if savefigs: + plt.savefig('freqplot-siso_bode-default.png') + + # Basic MIMO Bode plot + plt.figure() + sys_mimo = ct.tf2ss( + [[[1], [0.1]], [[0.2], [1]]], + [[[1, 0.6, 1], [1, 1, 1]], [[1, 0.4, 1], [1, 2, 1]]], name="MIMO") + ct.frequency_response(sys_mimo).plot() + if savefigs: + plt.savefig('freqplot-mimo_bode-default.png') + + # Magnitude only plot + plt.figure() + ct.frequency_response(sys_mimo).plot(plot_phase=False) + if savefigs: + plt.savefig('freqplot-mimo_bode-magonly.png') + + # Phase only plot + plt.figure() + ct.frequency_response(sys_mimo).plot(plot_magnitude=False) + + +if __name__ == "__main__": + # + # Interactive mode: generate plots for manual viewing + # + # Running this script in python (or better ipython) will show a + # collection of figures that should all look OK on the screeen. + # + + # In interactive mode, turn on ipython interactive graphics + plt.ion() + + # Start by clearing existing figures + plt.close('all') + + # Define and run a selected set of interesting tests + # TODO: TBD (see timeplot_test.py for format) + + test_basic_freq_plots(savefigs=True) + + # + # Run a few more special cases to show off capabilities (and save some + # of them for use in the documentation). + # + + pass diff --git a/control/tests/freqresp_test.py b/control/tests/freqresp_test.py index 9fc52112a..746e694be 100644 --- a/control/tests/freqresp_test.py +++ b/control/tests/freqresp_test.py @@ -273,8 +273,9 @@ def test_discrete(dsystem_type): else: # Calling bode should generate a not implemented error - with pytest.raises(NotImplementedError): - bode((dsys,)) + # with pytest.raises(NotImplementedError): + # TODO: check results + bode((dsys,)) def test_options(editsdefaults): diff --git a/control/tests/kwargs_test.py b/control/tests/kwargs_test.py index 19f4bb627..2f111f590 100644 --- a/control/tests/kwargs_test.py +++ b/control/tests/kwargs_test.py @@ -164,18 +164,22 @@ def test_matplotlib_kwargs(function, nsysargs, moreargs, kwargs, mplcleanup): @pytest.mark.parametrize( - "function", [control.time_response_plot, control.TimeResponseData.plot]) -def test_time_response_plot_kwargs(function): + "data_fcn, plot_fcn", [ + (control.step_response, control.time_response_plot), + (control.step_response, control.TimeResponseData.plot), + (control.frequency_response, control.FrequencyResponseData.plot), + ]) +def test_response_plot_kwargs(data_fcn, plot_fcn): # Create a system for testing - response = control.step_response(control.rss(4, 2, 2)) + response = data_fcn(control.rss(4, 2, 2)) # Call the plotting function normally and make sure it works - function(response) + plot_fcn(response) # Now add an unrecognized keyword and make sure there is an error with pytest.raises(AttributeError, match="(has no property|unexpected keyword)"): - function(response, unknown=None) + plot_fcn(response, unknown=None) # @@ -234,6 +238,7 @@ def test_time_response_plot_kwargs(function): 'flatsys.FlatSystem.__init__': test_unrecognized_kwargs, 'FrequencyResponseData.__init__': frd_test.TestFRD.test_unrecognized_keyword, + 'FrequencyResponseData.plot': test_response_plot_kwargs, 'InputOutputSystem.__init__': test_unrecognized_kwargs, 'flatsys.LinearFlatSystem.__init__': test_unrecognized_kwargs, 'NonlinearIOSystem.linearize': test_unrecognized_kwargs, diff --git a/control/timeplot.py b/control/timeplot.py index b6966fa16..c2a384888 100644 --- a/control/timeplot.py +++ b/control/timeplot.py @@ -604,35 +604,14 @@ def _make_line_label(signal_index, signal_labels, trace_index): ax = ax_array[i, j] # Get the labels to use labels = [line.get_label() for line in ax.get_lines()] - - # Look for a common prefix (up to a space) - common_prefix = commonprefix(labels) - last_space = common_prefix.rfind(', ') - if last_space < 0 or plot_inputs == 'overlay': - common_prefix = '' - elif last_space > 0: - common_prefix = common_prefix[:last_space] - prefix_len = len(common_prefix) - - # Look for a common suffice (up to a space) - common_suffix = commonprefix( - [label[::-1] for label in labels])[::-1] - suffix_len = len(common_suffix) - # Only chop things off after a comma or space - while suffix_len > 0 and common_suffix[-suffix_len] != ',': - suffix_len -= 1 - - # Strip the labels of common information - if suffix_len > 0: - labels = [label[prefix_len:-suffix_len] for label in labels] - else: - labels = [label[prefix_len:] for label in labels] + labels = _make_legend_labels(labels, plot_inputs == 'overlay') # Update the labels to remove common strings if len(labels) > 1 and legend_map[i, j] != None: with plt.rc_context(timeplot_rcParams): ax.legend(labels, loc=legend_map[i, j]) + # # Update the plot title (= figure suptitle) # @@ -806,3 +785,32 @@ def get_plot_axes(line_array): """ _get_axes = np.vectorize(lambda lines: lines[0].axes) return _get_axes(line_array) + + +# Utility function to make legend labels +def _make_legend_labels(labels, ignore_common=False): + + # Look for a common prefix (up to a space) + common_prefix = commonprefix(labels) + last_space = common_prefix.rfind(', ') + if last_space < 0 or ignore_common: + common_prefix = '' + elif last_space > 0: + common_prefix = common_prefix[:last_space] + prefix_len = len(common_prefix) + + # Look for a common suffice (up to a space) + common_suffix = commonprefix( + [label[::-1] for label in labels])[::-1] + suffix_len = len(common_suffix) + # Only chop things off after a comma or space + while suffix_len > 0 and common_suffix[-suffix_len] != ',': + suffix_len -= 1 + + # Strip the labels of common information + if suffix_len > 0: + labels = [label[prefix_len:-suffix_len] for label in labels] + else: + labels = [label[prefix_len:] for label in labels] + + return labels From a7e995135ecc0a86c03c9b640281e921f086697e Mon Sep 17 00:00:00 2001 From: Richard Murray Date: Thu, 6 Jul 2023 22:01:22 -0700 Subject: [PATCH 04/17] gangof4 refactoring into response/plot + related bode_plot changes --- control/frdata.py | 6 +- control/freqplot.py | 751 ++++++++++++++++----------- control/matlab/__init__.py | 1 + control/matlab/wrappers.py | 27 +- control/tests/config_test.py | 5 + control/tests/conftest.py | 16 +- control/tests/convert_test.py | 1 + control/tests/discrete_test.py | 1 + control/tests/freqplot_test.py | 167 +++++- control/tests/freqresp_test.py | 32 +- control/tests/kwargs_test.py | 15 +- control/tests/slycot_convert_test.py | 1 + 12 files changed, 687 insertions(+), 336 deletions(-) diff --git a/control/frdata.py b/control/frdata.py index f431966d1..e3b8a3a33 100644 --- a/control/frdata.py +++ b/control/frdata.py @@ -210,6 +210,10 @@ def __init__(self, *args, **kwargs): # If data was generated by a system, keep track of that self.sysname = kwargs.pop('sysname', None) + # Keep track of default properties for plotting + self.plot_phase=kwargs.pop('plot_phase', None) + self.title=kwargs.pop('title', None) + # Keep track of return type self.return_magphase=kwargs.pop('return_magphase', False) if self.return_magphase not in (True, False): @@ -650,7 +654,7 @@ def plot(self, *args, **kwargs): # For now, only support Bode plots # TODO: add 'kind' keyword and Nyquist plots (?) - bode_plot(self, *args, **kwargs) + return bode_plot(self, *args, **kwargs) # Convert to pandas def to_pandas(self): diff --git a/control/freqplot.py b/control/freqplot.py index fa698474b..7cc79d5f1 100644 --- a/control/freqplot.py +++ b/control/freqplot.py @@ -8,11 +8,18 @@ # [ ] Add mechanisms for storing/plotting margins? (currently forces FRD) # [ ] Allow line colors/styles to be set in plot() command (also time plots) # [ ] Allow bode or nyquist style plots from plot() -# [ ] Allow nyquist_curve() to generate the response curve (?) -# [ ] Allow MIMO frequency plots (w/ mag/phase subplots a la MATLAB) -# [ ] Update sisotool to use ax= -# [ ] Create __main__ in freqplot_test to view results (a la timeplot_test) +# [ ] Allow nyquist_response() to generate the response curve (?) +# [i] Allow MIMO frequency plots (w/ mag/phase subplots a la MATLAB) +# [i] Update sisotool to use ax= +# [i] Create __main__ in freqplot_test to view results (a la timeplot_test) # [ ] Get sisotool working in iPython and document how to make it work +# [i] Allow share_magnitude, share_phase, share_frequency keywords for units +# [ ] Re-implement including of gain/phase margin in the title (?) +# [i] Change gangof4 to use bode_plot(plot_phase=False) w/ proper labels +# [ ] Allow use of subplot labels instead of output/input subtitles +# [ ] Add line labels to gangof4 +# [ ] Update FRD to allow nyquist_response contours +# [ ] Allow frequency range to be overridden in bode_plot # # This file contains some standard control system plots: Bode plots, @@ -75,8 +82,9 @@ from .timeplot import _make_legend_labels from . import config -__all__ = ['bode_plot', 'nyquist_plot', 'gangof4_plot', 'singular_values_plot', - 'bode', 'nyquist', 'gangof4'] +__all__ = ['bode_plot', 'nyquist_plot', 'singular_values_plot', + 'gangof4_plot', 'gangof4_response', 'bode', 'nyquist', + 'gangof4'] # Default font dictionary _freqplot_rcParams = mpl.rcParams.copy() @@ -100,6 +108,9 @@ 'freqplot.grid': True, # Turn on grid for gain and phase 'freqplot.wrap_phase': False, # Wrap the phase plot at a given value 'freqplot.freq_label': "Frequency [%s]", + 'freqplot.share_magnitude': 'row', + 'freqplot.share_phase': 'row', + 'freqplot.share_frequency': 'col', # deprecations 'deprecated.bode.dB': 'freqplot.dB', @@ -123,9 +134,9 @@ def bode_plot( data, omega=None, *fmt, ax=None, omega_limits=None, omega_num=None, - plot=None, plot_magnitude=True, plot_phase=True, margins=None, + plot=None, plot_magnitude=True, plot_phase=None, margins=None, margin_info=False, method='best', legend_map=None, legend_loc=None, - title=None, relabel=True, **kwargs): + sharex=None, sharey=None, title=None, relabel=True, **kwargs): """Bode plot for a system. Bode plot of a frequency response over a (optional) frequency range. @@ -240,7 +251,6 @@ def bode_plot( 'freqplot', 'Hz', kwargs, _freqplot_defaults, pop=True) grid = config._get_param( 'freqplot', 'grid', kwargs, _freqplot_defaults, pop=True) - plot = config._get_param('freqplot', 'plot', plot, True) margins = config._get_param( 'freqplot', 'margins', margins, False) wrap_phase = config._get_param( @@ -253,6 +263,19 @@ def bode_plot( freq_label = config._get_param( 'freqplot', 'freq_label', kwargs, _freqplot_defaults, pop=True) + # Use sharex and sharey as proxies for share_{magnitude, phase, frequency} + if sharey is not None: + if 'share_magnitude' in kwargs or 'share_phase' in kwargs: + ValueError( + "sharey cannot be present with share_magnitude/share_phase") + kwargs['share_magnitude'] = sharey + kwargs['share_phase'] = sharey + if sharex is not None: + if 'share_frequency' in kwargs: + ValueError( + "sharex cannot be present with share_frequency") + kwargs['share_frequency'] = sharex + if not isinstance(data, (list, tuple)): data = [data] @@ -269,7 +292,7 @@ def bode_plot( plot = True # Keep track of legacy usage (see notes below) # - # Process the data to be plotted + # Pre-process the data to be plotted (unwrap phase) # # To maintain compatibility with legacy uses of bode_plot(), we do some # initial processing on the data, specifically phase unwrapping and @@ -277,31 +300,19 @@ def bode_plot( # plot == False, then these values are returned to the user (instead of # the list of lines created, which is the new output for _plot functions. # - # TODO: update to match timpelot inputs/outputs structure + + # If plot_phase is not specified, check the data first, otherwise true + if plot_phase is None: + plot_phase = True if data[0].plot_phase is None else data[0].plot_phase if not plot_magnitude and not plot_phase: raise ValueError( "plot_magnitude and plot_phase both False; no data to plot") - mags, phases, omegas, nyquistfrqs = [], [], [], [] - sysnames = [] + mag_data, phase_data, omega_data = [], [], [] for response in data: - mag, phase, omega_sys = response - mag = np.atleast_1d(mag) - phase = np.atleast_1d(phase) - # mag, phase = response.magnitude, response.phase # TODO: use this + phase = response.phase.copy() noutputs, ninputs = response.noutputs, response.ninputs - omega_sys = response.omega - nyquistfrq = None if response.isctime() else math.pi / response.dt - - ### - ### Code below can go into plotting section, but may need to - ### duplicate in frequency_response() ?? - ### - - # - # Post-process the phase to handle initial value and wrapping - # if initial_phase is None: # Start phase in the range 0 to -360 w/ initial phase = 0 @@ -314,42 +325,42 @@ def bode_plot( initial_phase_value = initial_phase/180. * math.pi else: initial_phase_value = initial_phase - else: raise ValueError("initial_phase must be a number.") - # TODO: hack to make sure that SISO case still works properly - my_phase = phase.reshape((noutputs, ninputs, -1)) # TODO: remove + # Reshape the phase to allow standard indexing + phase = phase.reshape((noutputs, ninputs, -1)) + + # Shift and wrap for i, j in itertools.product(range(noutputs), range(ninputs)): # Shift the phase if needed - if abs(my_phase[i, j, 0] - initial_phase_value) > math.pi: - my_phase[i, j] -= 2*math.pi * round( - (my_phase[i, j, 0] - initial_phase_value) / (2*math.pi)) + if abs(phase[i, j, 0] - initial_phase_value) > math.pi: + phase[i, j] -= 2*math.pi * round( + (phase[i, j, 0] - initial_phase_value) / (2*math.pi)) # Phase wrapping if wrap_phase is False: - my_phase[i, j] = unwrap(my_phase[i, j]) # unwrap the phase + phase[i, j] = unwrap(phase[i, j]) # unwrap the phase elif wrap_phase is True: pass # default calc OK elif isinstance(wrap_phase, (int, float)): - my_phase[i, j] = unwrap(my_phase[i, j]) # unwrap phase first + phase[i, j] = unwrap(phase[i, j]) # unwrap phase first if deg: wrap_phase *= math.pi/180. # Shift the phase if it is below the wrap_phase - my_phase[i, j] += 2*math.pi * np.maximum( - 0, np.ceil((wrap_phase - my_phase[i, j])/(2*math.pi))) + phase[i, j] += 2*math.pi * np.maximum( + 0, np.ceil((wrap_phase - phase[i, j])/(2*math.pi))) else: raise ValueError("wrap_phase must be bool or float.") - phase = my_phase.reshape(phase.shape) # TODO: remove - mags.append(mag) - phases.append(phase) - omegas.append(omega_sys) - nyquistfrqs.append(nyquistfrq) + # Put the phase back into the original shape + phase = phase.reshape(response.magnitude.shape) - # Save the system names for later - sysnames.append(response.sysname) + # Save the data for later use (legacy return values) + mag_data.append(response.magnitude) + phase_data.append(phase) + omega_data.append(response.omega) # # Process `plot` keyword @@ -389,10 +400,17 @@ def bode_plot( "use frequency_response()", DeprecationWarning) if plot is False: + # Process the data to match what we were sent + for i in range(len(mag_data)): + mag_data[i] = _process_frequency_response( + data[i], omega_data[i], mag_data[i], squeeze=data[i].squeeze) + phase_data[i] = _process_frequency_response( + data[i], omega_data[i], phase_data[i], squeeze=data[i].squeeze) + if len(data) == 1: - return mags[0], phases[0], omegas[0] + return mag_data[0], phase_data[0], omega_data[0] else: - return mags, phases, omegas + return mag_data, phase_data, omega_data # # Find/create axes # @@ -428,12 +446,11 @@ def bode_plot( # legend is generated. # - # Decide on the number of inputs and outputs + # Decide on the maximum number of inputs and outputs ninputs, noutputs = 0, 0 for response in data: # TODO: make more pythonic/numpic ninputs = max(ninputs, response.ninputs) noutputs = max(noutputs, response.noutputs) - ntraces = 1 # TODO: assume 1 trace per response for now # Figure how how many rows and columns to use + offsets for inputs/outputs nrows = (noutputs if plot_magnitude else 0) + \ @@ -447,26 +464,32 @@ def bode_plot( if len(ax) == nrows * ncols: # Assume that the shape is right (no easy way to infer this) ax = np.array(ax).reshape(nrows, ncols) + + # Clear out any old text from the current figure + for text in fig.texts: + text.set_visible(False) # turn off the text + del text # get rid of it completely + elif len(ax) != 0: # Need to generate a new figure fig, ax = plt.figure(), None + else: # Blank figure, just need to recreate axes ax = None - # Clear out any old text from the current figure - for text in fig.texts: - text.set_visible(False) # turn off the text - del text # get rid of it completely - # Create new axes, if needed, and customize them if ax is None: with plt.rc_context(_freqplot_rcParams): - ax_array = fig.subplots( - nrows, ncols, sharex='col', sharey='row', squeeze=False) + ax_array = fig.subplots(nrows, ncols, squeeze=False) fig.set_tight_layout(True) fig.align_labels() + # Set up default sharing of axis limits if not specified + for kw in ['share_magnitude', 'share_phase', 'share_frequency']: + if kw not in kwargs or kwargs[kw] is None: + kwargs[kw] = config.defaults['freqplot.' + kw] + else: # Make sure the axes are the right shape if ax.shape != (nrows, ncols): @@ -474,13 +497,96 @@ def bode_plot( "specified axes are not the right shape; " f"got {ax.shape} but expecting ({nrows}, {ncols})") ax_array = ax + fig = ax_array[0, 0].figure # just in case this is not gcf() + + # Get the values for sharing axes limits + share_magnitude = kwargs.pop('share_magnitude', None) + share_phase = kwargs.pop('share_phase', None) + share_frequency = kwargs.pop('share_frequency', None) + + # Set up axes variables for easier access below + if plot_magnitude and not plot_phase: + mag_map = np.empty((noutputs, ninputs), dtype=tuple) + for i in range(noutputs): + for j in range(ninputs): + mag_map[i, j] = (i, j) + phase_map = np.full((noutputs, ninputs), None) + share_phase = False + + elif plot_phase and not plot_magnitude: + phase_map = np.empty((noutputs, ninputs), dtype=tuple) + for i in range(noutputs): + for j in range(ninputs): + phase_map[i, j] = (i, j) + mag_map = np.full((noutputs, ninputs), None) + share_magnitude = False + + else: + mag_map = np.empty((noutputs, ninputs), dtype=tuple) + phase_map = np.empty((noutputs, ninputs), dtype=tuple) + for i in range(noutputs): + for j in range(ninputs): + mag_map[i, j] = (i*2, j) + phase_map[i, j] = (i*2 + 1, j) + + # Identity map needed for setting up shared axes + ax_map = np.empty((nrows, ncols), dtype=tuple) + for i, j in itertools.product(range(nrows), range(ncols)): + ax_map[i, j] = (i, j) + + # + # Set up axes limit sharing + # + # This code uses the share_magnitude, share_phase, and share_frequency + # keywords to decide which axes have shared limits and what ticklabels + # to include. The sharing code needs to come before the plots are + # generated, but additional code for removing tick labels needs to come + # *during* and *after* the plots are generated (see below). + # + # Note: if the various share_* keywords are None then a previous set of + # axes are available and no updates should be made. + # + + # Utility function to turn off sharing + def _share_axes(ref, share_map, axis): + ref_ax = ax_array[ref] + for index in np.nditer(share_map, flags=["refs_ok"]): + if index.item() == ref: + continue + if axis == 'x': + ax_array[index.item()].sharex(ref_ax) + elif axis == 'y': + ax_array[index.item()].sharey(ref_ax) + else: + raise ValueError("axis must be 'x' or 'y'") + + # Process magnitude, phase, and frequency axes + for name, value, map, axis in zip( + ['share_magnitude', 'shape_phase', 'share_frequency'], + [ share_magnitude, share_phase, share_frequency], + [ mag_map, phase_map, ax_map], + [ 'y', 'y', 'x']): + if value in [True, 'all']: + _share_axes(map[0 if axis == 'y' else -1, 0], map, axis) + elif axis == 'y' and value in ['row']: + for i in range(noutputs): + _share_axes(map[i, 0], map[i], 'y') + elif axis == 'x' and value in ['col']: + for j in range(ncols): + _share_axes(map[-1, j], map[j], 'x') + elif value in [False, 'none']: + # TODO: turn off any sharing that is on + pass + elif value is not None: + raise ValueError( + f"unknown value for `{name}`: '{value}'") # # Plot the data # - # The ax_magnitude and ax_phase arrays have the axes needed for making the - # plots. Labels are used on each axes for later creation of legends. - # The generic labels if of the form: + # The mag_map and phase_map arrays have the indices axes needed for + # making the plots. Labels are used on each axes for later creation of + # legends. The generic labels if of the form: # # To output label, From input label, system name # @@ -490,221 +596,277 @@ def bode_plot( # distinguishes which system signals are plotted. The system name is # stripped off later (in the legend-handling code) if it is not needed. # + # Note: if we are building on top of an existing plot, tick labels + # should be preserved from the existing axes. For log scale axes the + # tick labels seem to appear no matter what => we have to detect if + # they are present at the start and, it not, remove them after calling + # loglog or semilogx. + # - for mag, phase, omega_sys, nyquistfrq, sysname in \ - zip(mags, phases, omegas, nyquistfrqs, sysnames): + # Create a list of lines for the output + out = np.empty((nrows, ncols), dtype=object) + for i in range(nrows): + for j in range(ncols): + out[i, j] = [] # unique list in each element - # TODO: hack to handle MIMO while not breaking SISO - my_mag = mag.reshape((noutputs, ninputs, -1)) - my_phase = phase.reshape((noutputs, ninputs, -1)) - for i, j in itertools.product( - range(my_mag.shape[0]), range(my_mag.shape[1])): - if plot_magnitude: - ax_mag = ax_array[i*2 if plot_phase else i, j] - if plot_phase: - ax_phase = ax_array[i*2 + 1 if plot_magnitude else i, j] + for index, response in enumerate(data): + # Get the (pre-processed) data in fully indexed form + mag = mag_data[index].reshape((noutputs, ninputs, -1)) + phase = phase_data[index].reshape((noutputs, ninputs, -1)) + omega_sys, sysname = response.omega, response.sysname - nyquistfrq_plot = None - if Hz: - omega_plot = omega_sys / (2. * math.pi) - if nyquistfrq: - nyquistfrq_plot = nyquistfrq / (2. * math.pi) - else: - omega_plot = omega_sys - if nyquistfrq: - nyquistfrq_plot = nyquistfrq + # Keep track of Nyquist frequency for discrete time systems + nyq_freq = None if response.isctime() else math.pi / response.dt - phase_plot = my_phase[i, j] * 180. / math.pi if deg \ - else my_phase[i, j] - mag_plot = my_mag[i, j] - - if nyquistfrq_plot: - # append data for vertical nyquist freq indicator line. - # if this extra nyquist line 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_plot, nyquistfrq_plot)) - 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_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)) + for i, j in itertools.product(range(noutputs), range(ninputs)): + # Get the axes to use for magnitude and phase + ax_mag = ax_array[mag_map[i, j]] + ax_phase = ax_array[phase_map[i, j]] + + # Get the frequencies and convert to Hz, if needed + omega_plot = omega_sys / (2 * math.pi) if Hz else omega_sys + if nyq_freq is not None and Hz: + nyq_freq = nyq_freq / (2 * math.pi) + + # Save the magnitude and phase to plot + mag_plot = 20 * np.log10(mag[i, j]) if dB else mag[i, j] + phase_plot = phase[i, j] * 180. / math.pi if deg else phase[i, j] # Magnitude if plot_magnitude: - if dB: - ax_mag.semilogx( - omega_plot, 20 * np.log10(mag_plot), *fmt, - label=sysname, **kwargs) - else: - ax_mag.loglog( - omega_plot, mag_plot, *fmt, label=sysname, **kwargs) + pltfcn = ax_mag.semilogx if dB else ax_mag.loglog + convert = (lambda x: 20 * np.log10(x)) if dB else (lambda x: x) + + # Plot the main data + lines = pltfcn( + omega_plot, mag_plot, *fmt, label=sysname, **kwargs) + out[mag_map[i, j]] += lines + + # Plot vertical line at Nyquist frequency + # TODO: move this until after all data are plotted + if nyq_freq: + pltfcn( + [nyq_freq, nyq_freq], ax_mag.get_ylim(), + color=lines[0].get_color(), linestyle='--') # Add a grid to the plot + labeling ax_mag.grid(grid and not margins, which='both') # Phase if plot_phase: - ax_phase.semilogx( + lines = ax_phase.semilogx( omega_plot, phase_plot, *fmt, label=sysname, **kwargs) + out[phase_map[i, j]] += lines - # - # Plot gain and phase margins - # + # Plot vertical line at Nyquist frequency + # TODO: move this until after all data are plotted + if nyq_freq: + ax_phase.semilogx( + [nyq_freq, nyq_freq], ax_phase.get_ylim(), + color=lines[0].get_color(), linestyle='--') - # Show the phase and gain margins in the plot - if margins: - # Compute stability margins for the system - margin = stability_margins(response, method=method) - 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) - phase_at_cp = phases[0][(np.abs(omegas[0] - Wcp)).argmin()] - if phase_at_cp >= 0.: - phase_limit = 180. - else: - phase_limit = -180. + # Add a grid to the plot + labeling + ax_phase.grid(grid and not margins, which='both') - if Hz: - Wcg, Wcp = Wcg/(2*math.pi), Wcp/(2*math.pi) + # + # Plot gain and phase margins (SISO only) + # - # Draw lines at gain and phase limits - if plot_magnitude: - ax_mag.axhline(y=0 if dB else 1, color='k', linestyle=':', - zorder=-20) - mag_ylim = ax_mag.get_ylim() + # Show the phase and gain margins in the plot + if margins: + if ninputs > 1 or noutputs > 1: + raise NotImplementedError( + "margins are not available for MIMO systems") + + # Compute stability margins for the system + margin = stability_margins(response, method=method) + 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) + phase_at_cp = phase[ + 0, 0, (np.abs(omega_data[0] - Wcp)).argmin()] + if phase_at_cp >= 0.: + phase_limit = 180. + else: + phase_limit = -180. - if plot_phase: - ax_phase.axhline(y=phase_limit if deg else - math.radians(phase_limit), - color='k', linestyle=':', zorder=-20) - phase_ylim = ax_phase.get_ylim() - - # Annotate the phase margin (if it exists) - if plot_phase and pm != float('inf') and Wcp != float('nan'): - if dB: - ax_mag.semilogx( - [Wcp, Wcp], [0., -1e5], - color='k', linestyle=':', zorder=-20) - else: - ax_mag.loglog( - [Wcp, Wcp], [1., 1e-8], - color='k', linestyle=':', zorder=-20) + if Hz: + Wcg, Wcp = Wcg/(2*math.pi), Wcp/(2*math.pi) + # Draw lines at gain and phase limits + if plot_magnitude: + ax_mag.axhline(y=0 if dB else 1, color='k', linestyle=':', + zorder=-20) + mag_ylim = ax_mag.get_ylim() + + if plot_phase: + ax_phase.axhline(y=phase_limit if deg else + math.radians(phase_limit), + color='k', linestyle=':', zorder=-20) + phase_ylim = ax_phase.get_ylim() + + # Annotate the phase margin (if it exists) + if plot_phase and pm != float('inf') and Wcp != float('nan'): + if dB: + ax_mag.semilogx( + [Wcp, Wcp], [0., -1e5], + color='k', linestyle=':', zorder=-20) + else: + ax_mag.loglog( + [Wcp, Wcp], [1., 1e-8], + color='k', linestyle=':', zorder=-20) + + if deg: + ax_phase.semilogx( + [Wcp, Wcp], [1e5, phase_limit + pm], + color='k', linestyle=':', zorder=-20) + ax_phase.semilogx( + [Wcp, Wcp], [phase_limit + pm, phase_limit], + color='k', zorder=-20) + else: + ax_phase.semilogx( + [Wcp, Wcp], [1e5, math.radians(phase_limit) + + math.radians(pm)], + color='k', linestyle=':', zorder=-20) + ax_phase.semilogx( + [Wcp, Wcp], [math.radians(phase_limit) + + math.radians(pm), + math.radians(phase_limit)], + color='k', zorder=-20) + + ax_phase.set_ylim(phase_ylim) + + # Annotate the gain margin (if it exists) + if plot_magnitude and gm != float('inf') and \ + Wcg != float('nan'): + if dB: + ax_mag.semilogx( + [Wcg, Wcg], [-20.*np.log10(gm), -1e5], + color='k', linestyle=':', zorder=-20) + ax_mag.semilogx( + [Wcg, Wcg], [0, -20*np.log10(gm)], + color='k', zorder=-20) + else: + ax_mag.loglog( + [Wcg, Wcg], [1./gm, 1e-8], color='k', + linestyle=':', zorder=-20) + ax_mag.loglog( + [Wcg, Wcg], [1., 1./gm], color='k', zorder=-20) + + if plot_phase: if deg: ax_phase.semilogx( - [Wcp, Wcp], [1e5, phase_limit + pm], + [Wcg, Wcg], [0, phase_limit], color='k', linestyle=':', zorder=-20) - ax_phase.semilogx( - [Wcp, Wcp], [phase_limit + pm, phase_limit], - color='k', zorder=-20) else: ax_phase.semilogx( - [Wcp, Wcp], [1e5, math.radians(phase_limit) + - math.radians(pm)], - color='k', linestyle=':', zorder=-20) - ax_phase.semilogx( - [Wcp, Wcp], [math.radians(phase_limit) + - math.radians(pm), - math.radians(phase_limit)], - color='k', zorder=-20) - - ax_phase.set_ylim(phase_ylim) - - # Annotate the gain margin (if it exists) - if plot_magnitude and gm != float('inf') and \ - Wcg != float('nan'): - if dB: - ax_mag.semilogx( - [Wcg, Wcg], [-20.*np.log10(gm), -1e5], + [Wcg, Wcg], [0, math.radians(phase_limit)], color='k', linestyle=':', zorder=-20) - ax_mag.semilogx( - [Wcg, Wcg], [0, -20*np.log10(gm)], - color='k', zorder=-20) - else: - ax_mag.loglog( - [Wcg, Wcg], [1./gm, 1e-8], color='k', - linestyle=':', zorder=-20) - ax_mag.loglog( - [Wcg, Wcg], [1., 1./gm], color='k', zorder=-20) - - if plot_phase: - if deg: - ax_phase.semilogx( - [Wcg, Wcg], [0, phase_limit], - color='k', linestyle=':', zorder=-20) - else: - ax_phase.semilogx( - [Wcg, Wcg], [0, math.radians(phase_limit)], - color='k', linestyle=':', zorder=-20) - - ax_mag.set_ylim(mag_ylim) - ax_phase.set_ylim(phase_ylim) - - if margin_info: - if plot_magnitude: - ax_mag.text( - 0.04, 0.06, - 'G.M.: %.2f %s\nFreq: %.2f %s' % - (20*np.log10(gm) if dB else gm, - 'dB ' if dB else '', - Wcg, 'Hz' if Hz else 'rad/s'), - horizontalalignment='left', - verticalalignment='bottom', - transform=ax_mag.transAxes, - fontsize=8 if int(mpl.__version__[0]) == 1 else 6) - if plot_phase: - ax_phase.text( - 0.04, 0.06, - 'P.M.: %.2f %s\nFreq: %.2f %s' % - (pm if deg else math.radians(pm), - 'deg' if deg else 'rad', - Wcp, 'Hz' if Hz else 'rad/s'), - horizontalalignment='left', - verticalalignment='bottom', - transform=ax_phase.transAxes, - fontsize=8 if int(mpl.__version__[0]) == 1 else 6) - else: - # TODO: gets overwritten below - plt.suptitle( - "Gm = %.2f %s(at %.2f %s), " - "Pm = %.2f %s (at %.2f %s)" % + + ax_mag.set_ylim(mag_ylim) + ax_phase.set_ylim(phase_ylim) + + if margin_info: + if plot_magnitude: + ax_mag.text( + 0.04, 0.06, + 'G.M.: %.2f %s\nFreq: %.2f %s' % (20*np.log10(gm) if dB else gm, 'dB ' if dB else '', - Wcg, 'Hz' if Hz else 'rad/s', - pm if deg else math.radians(pm), + Wcg, 'Hz' if Hz else 'rad/s'), + horizontalalignment='left', + verticalalignment='bottom', + transform=ax_mag.transAxes, + fontsize=8 if int(mpl.__version__[0]) == 1 else 6) + if plot_phase: + ax_phase.text( + 0.04, 0.06, + 'P.M.: %.2f %s\nFreq: %.2f %s' % + (pm if deg else math.radians(pm), 'deg' if deg else 'rad', - Wcp, 'Hz' if Hz else 'rad/s')) + Wcp, 'Hz' if Hz else 'rad/s'), + horizontalalignment='left', + verticalalignment='bottom', + transform=ax_phase.transAxes, + fontsize=8 if int(mpl.__version__[0]) == 1 else 6) + else: + # TODO: gets overwritten below + plt.suptitle( + "Gm = %.2f %s(at %.2f %s), " + "Pm = %.2f %s (at %.2f %s)" % + (20*np.log10(gm) if dB else gm, + 'dB ' if dB else '', + Wcg, 'Hz' if Hz else 'rad/s', + pm if deg else math.radians(pm), + 'deg' if deg else 'rad', + Wcp, 'Hz' if Hz else 'rad/s')) + # + # Finishing handling axes limit sharing + # + # This code handles labels on phase plots and also removes tick labels + # on shared axes. It needs to come *after* the plots are generated, + # in order to handle two things: + # + # * manually generated labels and grids need to reflect the limts for + # shared axes, which we don't know until we have plotted everything; + # + # * the use of loglog and semilog regenerate the labels (not quite sure + # why, since using sharex and sharey in subplots does not have this + # behavior). + # + # Note: as before, if the various share_* keywords are None then a + # previous set of axes are available and no updates are made. + # + + for i in range(noutputs): + for j in range(ninputs): def gen_zero_centered_series(val_min, val_max, period): v1 = np.ceil(val_min / period - 0.2) v2 = np.floor(val_max / period + 0.2) return np.arange(v1, v2 + 1) * period + # TODO: put Nyquist lines here? + # TODO: what is going on here # TODO: fix to use less dense labels, when needed + # TODO: make sure turning sharey on and off makes labels come/go if plot_phase: + ax_phase = ax_array[phase_map[i, j]] + + # Set the labels + # TODO: tighten up code if deg: ylim = ax_phase.get_ylim() + num = np.floor((ylim[1] - ylim[0]) / 45) + factor = max(1, np.round(num / (32 / nrows)) * 2) ax_phase.set_yticks(gen_zero_centered_series( - ylim[0], ylim[1], 45.)) + ylim[0], ylim[1], 45 * factor)) ax_phase.set_yticks(gen_zero_centered_series( - ylim[0], ylim[1], 15.), minor=True) + ylim[0], ylim[1], 15 * factor), minor=True) else: ylim = ax_phase.get_ylim() + num = np.ceil((ylim[1] - ylim[0]) / (math.pi/4)) + factor = max(1, np.round(num / (36 / nrows)) * 2) ax_phase.set_yticks(gen_zero_centered_series( - ylim[0], ylim[1], math.pi / 4.)) + ylim[0], ylim[1], math.pi / 4. * factor)) ax_phase.set_yticks(gen_zero_centered_series( - ylim[0], ylim[1], math.pi / 12.), minor=True) - - ax_phase.grid(grid and not margins, which='both') + ylim[0], ylim[1], math.pi / 12. * factor), minor=True) + + # Turn off y tick labels for shared axes + for i in range(0, noutputs): + for j in range(1, ncols): + if share_magnitude in [True, 'all', 'row']: + ax_array[mag_map[i, j]].tick_params(labelleft=False) + if share_phase in [True, 'all', 'row']: + ax_array[phase_map[i, j]].tick_params(labelleft=False) + + # Turn off x tick labels for shared axes + for i in range(0, nrows-1): + for j in range(0, ncols): + if share_frequency in [True, 'all', 'col']: + ax_array[i, j].tick_params(labelbottom=False) # # Update the plot title (= figure suptitle) @@ -716,10 +878,13 @@ def gen_zero_centered_series(val_min, val_max, period): # list of systems (e.g., "Step response for sys[1], sys[2]"). # - # Set the initial title for the data - sysnames = list(set(sysnames)) # get rid of duplicates + # Set the initial title for the data (unique system names) + sysnames = list(set([response.sysname for response in data])) if title is None: - title = "Bode plot for " + ", ".join(sysnames) + if data[0].title is None: + title = "Bode plot for " + ", ".join(sysnames) + else: + title = data[0].title if fig is not None and title is not None: # Get the current title, if it exists @@ -774,7 +939,7 @@ def gen_zero_centered_series(val_min, val_max, period): ax_mag.set_ylabel("Magnitude (dB)" if dB else "Magnitude") if plot_phase: ax_phase = ax_array[i*2 + 1 if plot_magnitude else i, 0] - ax_phase.set_ylabel("Phase (deg)" if deg else "Phase (rad)") + ax_phase.set_ylabel("Phase [deg]" if deg else "Phase [rad]") if noutputs > 1 or ninputs > 1: if plot_magnitude and plot_phase: @@ -851,7 +1016,8 @@ def gen_zero_centered_series(val_min, val_max, period): ax = ax_array[i, j] # Get the labels to use, removing common strings labels = _make_legend_labels( - [line.get_label() for line in ax.get_lines()]) + [line.get_label() for line in ax.get_lines() + if line.get_label()[0] != '_']) # Generate the label, if needed if len(labels) > 1 and legend_map[i, j] != None: @@ -862,12 +1028,19 @@ def gen_zero_centered_series(val_min, val_max, period): # Legacy return pocessing # if plot is True: # legacy usage; remove in future release + # Process the data to match what we were sent + for i in range(len(mag_data)): + mag_data[i] = _process_frequency_response( + data[i], omega_data[i], mag_data[i], squeeze=data[i].squeeze) + phase_data[i] = _process_frequency_response( + data[i], omega_data[i], phase_data[i], squeeze=data[i].squeeze) + if len(data) == 1: - return mags[0], phases[0], omegas[0] + return mag_data[0], phase_data[0], omega_data[0] else: - return mags, phases, omegas + return mag_data, phase_data, omega_data - return None # TODO: replace with ax + return out # @@ -1602,12 +1775,11 @@ def _compute_curve_offset(resp, mask, max_offset): # # Gang of Four plot # -# TODO: think about how (and whether) to handle lists of systems -def gangof4_plot(P, C, omega=None, **kwargs): - """Plot the "Gang of 4" transfer functions for a system. +def gangof4_response(P, C, omega=None, Hz=False): + """Compute the response of the "Gang of 4" transfer functions for a system. Generates a 2x2 plot showing the "Gang of 4" sensitivity functions - [T, PS; CS, S] + [T, PS; CS, S]. Parameters ---------- @@ -1615,8 +1787,6 @@ def gangof4_plot(P, C, omega=None, **kwargs): Linear input/output systems (process and control) omega : array Range of frequencies (list or bounds) in rad/sec - **kwargs : :func:`matplotlib.pyplot.plot` keyword properties, optional - Additional keywords (passed to `matplotlib`) Returns ------- @@ -1634,14 +1804,6 @@ def gangof4_plot(P, C, omega=None, **kwargs): raise ControlMIMONotImplemented( "Gang of four is currently only implemented for SISO systems.") - # Get the default parameter values - dB = config._get_param( - 'freqplot', 'dB', kwargs, _freqplot_defaults, pop=True) - Hz = config._get_param( - 'freqplot', 'Hz', kwargs, _freqplot_defaults, pop=True) - grid = config._get_param( - 'freqplot', 'grid', kwargs, _freqplot_defaults, pop=True) - # Compute the senstivity functions L = P * C S = feedback(1, L) @@ -1652,81 +1814,35 @@ def gangof4_plot(P, C, omega=None, **kwargs): if omega is None: omega = _default_frequency_range((P, C, S), Hz=Hz) - # Set up the axes with labels so that multiple calls to - # gangof4_plot will superimpose the data. See details in bode_plot. - plot_axes = {'t': None, 's': None, 'ps': None, 'cs': None} - for ax in plt.gcf().axes: - label = ax.get_label() - if label.startswith('control-gangof4-'): - key = label[len('control-gangof4-'):] - if key not in plot_axes: - raise RuntimeError( - "unknown gangof4 axis type '{}'".format(label)) - plot_axes[key] = ax - - # if any of the axes are missing, start from scratch - if any((ax is None for ax in plot_axes.values())): - plt.clf() - plot_axes = {'s': plt.subplot(221, label='control-gangof4-s'), - 'ps': plt.subplot(222, label='control-gangof4-ps'), - 'cs': plt.subplot(223, label='control-gangof4-cs'), - 't': plt.subplot(224, label='control-gangof4-t')} - # - # Plot the four sensitivity functions + # bode_plot based implementation # - omega_plot = omega / (2. * math.pi) if Hz else omega - # TODO: Need to add in the mag = 1 lines - mag_tmp, phase_tmp, omega = S.frequency_response(omega) - mag = np.squeeze(mag_tmp) - if dB: - plot_axes['s'].semilogx(omega_plot, 20 * np.log10(mag), **kwargs) - else: - plot_axes['s'].loglog(omega_plot, mag, **kwargs) - plot_axes['s'].set_ylabel("$|S|$" + " (dB)" if dB else "") - plot_axes['s'].tick_params(labelbottom=False) - plot_axes['s'].grid(grid, which='both') - - mag_tmp, phase_tmp, omega = (P * S).frequency_response(omega) - mag = np.squeeze(mag_tmp) - if dB: - plot_axes['ps'].semilogx(omega_plot, 20 * np.log10(mag), **kwargs) - else: - plot_axes['ps'].loglog(omega_plot, mag, **kwargs) - plot_axes['ps'].tick_params(labelbottom=False) - plot_axes['ps'].set_ylabel("$|PS|$" + " (dB)" if dB else "") - plot_axes['ps'].grid(grid, which='both') - - mag_tmp, phase_tmp, omega = (C * S).frequency_response(omega) - mag = np.squeeze(mag_tmp) - if dB: - plot_axes['cs'].semilogx(omega_plot, 20 * np.log10(mag), **kwargs) - else: - plot_axes['cs'].loglog(omega_plot, mag, **kwargs) - plot_axes['cs'].set_xlabel( - "Frequency (Hz)" if Hz else "Frequency (rad/sec)") - plot_axes['cs'].set_ylabel("$|CS|$" + " (dB)" if dB else "") - plot_axes['cs'].grid(grid, which='both') - - mag_tmp, phase_tmp, omega = T.frequency_response(omega) - mag = np.squeeze(mag_tmp) - if dB: - plot_axes['t'].semilogx(omega_plot, 20 * np.log10(mag), **kwargs) - else: - plot_axes['t'].loglog(omega_plot, mag, **kwargs) - plot_axes['t'].set_xlabel( - "Frequency (Hz)" if Hz else "Frequency (rad/sec)") - plot_axes['t'].set_ylabel("$|T|$" + " (dB)" if dB else "") - plot_axes['t'].grid(grid, which='both') + # Compute the response of the Gang of 4 + resp_T = T(1j * omega) + resp_PS = (P * S)(1j * omega) + resp_CS = (C * S)(1j * omega) + resp_S = S(1j * omega) + + # Create a single frequency response data object with the underlying data + data = np.empty((2, 2, omega.size), dtype=complex) + data[0, 0, :] = resp_T + data[0, 1, :] = resp_PS + data[1, 0, :] = resp_CS + data[1, 1, :] = resp_S + + return FrequencyResponseData( + data, omega, outputs=['y', 'u'], inputs=['r', 'd'], + title=f"Gang of Four for P={P.name}, C={C.name}", plot_phase=False) - plt.tight_layout() + +def gangof4_plot(P, C, omega=None, **kwargs): + """Legacy Gang of 4 plot; use gangof4_response().plot() instead.""" + return gangof4_response(P, C).plot(**kwargs) # # Singular values plot # - - def singular_values_plot(syslist, omega=None, plot=True, omega_limits=None, omega_num=None, *args, **kwargs): @@ -1855,6 +1971,7 @@ def singular_values_plot(syslist, omega=None, color = color_cycle[(idx_sys + color_offset) % len(color_cycle)] color = kwargs.pop('color', color) + # TODO: copy from above nyquistfrq_plot = None if Hz: omega_plot = omega_sys / (2. * math.pi) diff --git a/control/matlab/__init__.py b/control/matlab/__init__.py index 4b723c984..b02d16d53 100644 --- a/control/matlab/__init__.py +++ b/control/matlab/__init__.py @@ -87,6 +87,7 @@ # Functions that are renamed in MATLAB pole, zero = poles, zeros +freqresp = frequency_response # Import functions specific to Matlab compatibility package from .timeresp import * diff --git a/control/matlab/wrappers.py b/control/matlab/wrappers.py index 041ca8bd0..59c6cc7f3 100644 --- a/control/matlab/wrappers.py +++ b/control/matlab/wrappers.py @@ -64,16 +64,27 @@ def bode(*args, **kwargs): """ from ..freqplot import bode_plot - # If first argument is a list, assume python-control calling format - if hasattr(args[0], '__iter__'): - return bode_plot(*args, **kwargs) + # Turn off deprecation warning + with warnings.catch_warnings(): + warnings.filterwarnings( + 'ignore', message='passing systems .* is deprecated', + category=DeprecationWarning) + warnings.filterwarnings( + 'ignore', message='.* return values of .* is deprecated', + category=DeprecationWarning) + + # If first argument is a list, assume python-control calling format + if hasattr(args[0], '__iter__'): + retval = bode_plot(*args, **kwargs) + else: + # Parse input arguments + syslist, omega, args, other = _parse_freqplot_args(*args) + kwargs.update(other) - # Parse input arguments - syslist, omega, args, other = _parse_freqplot_args(*args) - kwargs.update(other) + # Call the bode command + retval = bode_plot(syslist, omega, *args, **kwargs) - # Call the bode command - return bode_plot(syslist, omega, *args, **kwargs) + return retval def nyquist(*args, **kwargs): diff --git a/control/tests/config_test.py b/control/tests/config_test.py index 5ea99d264..48737eaa8 100644 --- a/control/tests/config_test.py +++ b/control/tests/config_test.py @@ -89,6 +89,7 @@ def test_default_deprecation(self): assert ct.config.defaults['bode.Hz'] \ == ct.config.defaults['freqplot.Hz'] + @pytest.mark.usefixtures("legacy_plot_signature") def test_fbs_bode(self, mplcleanup): ct.use_fbs_defaults() @@ -133,6 +134,7 @@ def test_fbs_bode(self, mplcleanup): phase_x, phase_y = (((plt.gcf().axes[1]).get_lines())[0]).get_data() np.testing.assert_almost_equal(phase_y[-1], -pi, decimal=2) + @pytest.mark.usefixtures("legacy_plot_signature") def test_matlab_bode(self, mplcleanup): ct.use_matlab_defaults() @@ -177,6 +179,7 @@ def test_matlab_bode(self, mplcleanup): phase_x, phase_y = (((plt.gcf().axes[1]).get_lines())[0]).get_data() np.testing.assert_almost_equal(phase_y[-1], -pi, decimal=2) + @pytest.mark.usefixtures("legacy_plot_signature") def test_custom_bode_default(self, mplcleanup): ct.config.defaults['freqplot.dB'] = True ct.config.defaults['freqplot.deg'] = True @@ -198,6 +201,7 @@ def test_custom_bode_default(self, mplcleanup): np.testing.assert_almost_equal(mag_y[0], 20*log10(10), decimal=3) np.testing.assert_almost_equal(phase_y[-1], -pi, decimal=2) + @pytest.mark.usefixtures("legacy_plot_signature") def test_bode_number_of_samples(self, mplcleanup): # Set the number of samples (default is 50, from np.logspace) mag_ret, phase_ret, omega_ret = ct.bode_plot(self.sys, omega_num=87) @@ -212,6 +216,7 @@ def test_bode_number_of_samples(self, mplcleanup): mag_ret, phase_ret, omega_ret = ct.bode_plot(self.sys, omega_num=87) assert len(mag_ret) == 87 + @pytest.mark.usefixtures("legacy_plot_signature") def test_bode_feature_periphery_decade(self, mplcleanup): # Generate a sample Bode plot to figure out the range it uses ct.reset_defaults() # Make sure starting state is correct diff --git a/control/tests/conftest.py b/control/tests/conftest.py index c5ab6cb86..338a7088c 100644 --- a/control/tests/conftest.py +++ b/control/tests/conftest.py @@ -9,8 +9,6 @@ import control -TEST_MATRIX_AND_ARRAY = os.getenv("PYTHON_CONTROL_ARRAY_AND_MATRIX") == "1" - # some common pytest marks. These can be used as test decorators or in # pytest.param(marks=) slycotonly = pytest.mark.skipif( @@ -61,6 +59,20 @@ def mplcleanup(): mpl.pyplot.close("all") +@pytest.fixture(scope="function") +def legacy_plot_signature(): + """Turn off warnings for calls to plotting functions with old signatures""" + import warnings + warnings.filterwarnings( + 'ignore', message='passing systems .* is deprecated', + category=DeprecationWarning) + warnings.filterwarnings( + 'ignore', message='.* return values of .* is deprecated', + category=DeprecationWarning) + yield + warnings.resetwarnings() + + # Allow pytest.mark.slow to mark slow tests (skip with pytest -m "not slow") def pytest_configure(config): config.addinivalue_line("markers", "slow: mark test as slow to run") diff --git a/control/tests/convert_test.py b/control/tests/convert_test.py index db173b653..14f3133e1 100644 --- a/control/tests/convert_test.py +++ b/control/tests/convert_test.py @@ -48,6 +48,7 @@ def printSys(self, sys, ind): print("sys%i:\n" % ind) print(sys) + @pytest.mark.usefixtures("legacy_plot_signature") @pytest.mark.parametrize("states", range(1, maxStates)) @pytest.mark.parametrize("inputs", range(1, maxIO)) @pytest.mark.parametrize("outputs", range(1, maxIO)) diff --git a/control/tests/discrete_test.py b/control/tests/discrete_test.py index 4415fac0c..e8a6b5199 100644 --- a/control/tests/discrete_test.py +++ b/control/tests/discrete_test.py @@ -460,6 +460,7 @@ def test_sample_tf(self, tsys): np.testing.assert_array_almost_equal(numd, numd_expected) np.testing.assert_array_almost_equal(dend, dend_expected) + @pytest.mark.usefixtures("legacy_plot_signature") def test_discrete_bode(self, tsys): # Create a simple discrete time system and check the calculation sys = TransferFunction([1], [1, 0.5], 1) diff --git a/control/tests/freqplot_test.py b/control/tests/freqplot_test.py index aa2fb9dc0..9299181b0 100644 --- a/control/tests/freqplot_test.py +++ b/control/tests/freqplot_test.py @@ -10,6 +10,134 @@ from control.tests.conftest import slycotonly pytestmark = pytest.mark.usefixtures("mplcleanup") +# +# Define a system for testing out different sharing options +# + +omega = np.logspace(-2, 2, 5) +fresp1 = np.array([10 + 0j, 5 - 5j, 1 - 1j, 0.5 - 1j, -.1j]) +fresp2 = np.array([1j, 0.5 - 0.5j, -0.5, 0.1 - 0.1j, -.05j]) * 0.1 +fresp3 = np.array([10 + 0j, -20j, -10, 2j, 1]) +fresp4 = np.array([10 + 0j, 5 - 5j, 1 - 1j, 0.5 - 1j, -.1j]) * 0.01 + +fresp = np.empty((2, 2, omega.size), dtype=complex) +fresp[0, 0] = fresp1 +fresp[0, 1] = fresp2 +fresp[1, 0] = fresp3 +fresp[1, 1] = fresp4 +manual_response = ct.FrequencyResponseData( + fresp, omega, sysname="Manual Response") + +@pytest.mark.parametrize( + "sys", [ + ct.tf([1], [1, 2, 1], name='System 1'), # SISO + manual_response, # simple MIMO + ]) +# @pytest.mark.parametrize("pltmag", [True, False]) +# @pytest.mark.parametrize("pltphs", [True, False]) +# @pytest.mark.parametrize("shrmag", ['row', 'all', False, None]) +# @pytest.mark.parametrize("shrphs", ['row', 'all', False, None]) +# @pytest.mark.parametrize("shrfrq", ['col', 'all', False, None]) +# @pytest.mark.parametrize("secsys", [False, True]) +@pytest.mark.parametrize( # combinatorial-style test (faster) + "pltmag, pltphs, shrmag, shrphs, shrfrq, secsys", + [(True, True, None, None, None, False), + (True, False, None, None, None, False), + (False, True, None, None, None, False), + (True, True, None, None, None, True), + (True, True, 'row', 'row', 'col', False), + (True, True, 'row', 'row', 'all', True), + (True, True, 'all', 'row', None, False), + (True, True, 'row', 'all', None, True), + (True, True, 'none', 'none', None, True), + (True, False, 'all', 'row', None, False), + (True, True, True, 'row', None, True), + (True, True, None, 'row', True, False), + (True, True, 'row', None, None, True), + ]) +def test_response_plots( + sys, pltmag, pltphs, shrmag, shrphs, shrfrq, secsys, clear=True): + + # Save up the keyword arguments + kwargs = dict( + plot_magnitude=pltmag, plot_phase=pltphs, + share_magnitude=shrmag, share_phase=shrphs, share_frequency=shrfrq, + # overlay_outputs=ovlout, overlay_inputs=ovlinp + ) + + # Create the response + if isinstance(sys, ct.FrequencyResponseData): + response = sys + else: + response = ct.frequency_response(sys) + + # Look for cases where there are no data to plot + if not pltmag and not pltphs: + return None + + # Plot the frequency response + plt.figure() + out = response.plot(**kwargs) + + # Make sure all of the outputs are of the right type + nlines_plotted = 0 + for ax_lines in np.nditer(out, flags=["refs_ok"]): + for line in ax_lines.item(): + assert isinstance(line, mpl.lines.Line2D) + nlines_plotted += 1 + + # Make sure number of plots is correct + nlines_expected = response.ninputs * response.noutputs * \ + (2 if pltmag and pltphs else 1) + assert nlines_plotted == nlines_expected + + # Save the old axes to compare later + old_axes = plt.gcf().get_axes() + + # Add additional data (and provide info in the title) + if secsys: + newsys = ct.rss( + 4, sys.noutputs, sys.ninputs, strictly_proper=True) + ct.frequency_response(newsys).plot(**kwargs) + + # Make sure we have the same axes + new_axes = plt.gcf().get_axes() + assert new_axes == old_axes + + # Make sure every axes has multiple lines + for ax in new_axes: + assert len(ax.get_lines()) > 1 + + # Update the title so we can see what is going on + fig = out[0, 0][0].axes.figure + fig.suptitle( + fig._suptitle._text + + f" [{sys.noutputs}x{sys.ninputs}, pm={pltmag}, pp={pltphs}," + f" sm={shrmag}, sp={shrphs}, sf={shrfrq}]", # TODO: ", " + # f"oo={ovlout}, oi={ovlinp}, ss={secsys}]", # TODO: add back + fontsize='small') + + # Get rid of the figure to free up memory + if clear: + plt.close('.Figure') + + +# Use the manaul response to verify that different settings are working +def test_manual_response_limits(): + # Default response: limits should be the same across rows + out = manual_response.plot() + axs = ct.get_plot_axes(out) + for i in range(manual_response.noutputs): + for j in range(1, manual_response.ninputs): + # Everything in the same row should have the same limits + assert axs[i*2, 0].get_ylim() == axs[i*2, j].get_ylim() + assert axs[i*2 + 1, 0].get_ylim() == axs[i*2 + 1, j].get_ylim() + # Different rows have different limits + assert axs[0, 0].get_ylim() != axs[2, 0].get_ylim() + assert axs[1, 0].get_ylim() != axs[3, 0].get_ylim() + + # TODO: finish writing tests + def test_basic_freq_plots(savefigs=False): # Basic SISO Bode plot plt.figure() @@ -23,7 +151,7 @@ def test_basic_freq_plots(savefigs=False): # Basic MIMO Bode plot plt.figure() - sys_mimo = ct.tf2ss( + sys_mimo = ct.tf( [[[1], [0.1]], [[0.2], [1]]], [[[1, 0.6, 1], [1, 1, 1]], [[1, 0.4, 1], [1, 2, 1]]], name="MIMO") ct.frequency_response(sys_mimo).plot() @@ -41,6 +169,17 @@ def test_basic_freq_plots(savefigs=False): ct.frequency_response(sys_mimo).plot(plot_magnitude=False) +def test_gangof4_plots(savefigs=False): + proc = ct.tf([1], [1, 1, 1], name="process") + ctrl = ct.tf([100], [1, 5], name="control") + + plt.figure() + ct.gangof4_plot(proc, ctrl) + + if savefigs: + plt.savefig('freqplot-gangof4.png') + + if __name__ == "__main__": # # Interactive mode: generate plots for manual viewing @@ -55,10 +194,36 @@ def test_basic_freq_plots(savefigs=False): # Start by clearing existing figures plt.close('all') + # Define a set of systems to test + sys_siso = ct.tf([1], [1, 2, 1], name="SISO") + sys_mimo = ct.tf( + [[[1], [0.1]], [[0.2], [1]]], + [[[1, 0.6, 1], [1, 1, 1]], [[1, 0.4, 1], [1, 2, 1]]], name="MIMO") + sys_test = manual_response + + # Run through a large number of test cases + test_cases = [ + # sys pltmag pltphs shrmag shrphs shrfrq secsys + (sys_siso, True, True, None, None, None, False), + (sys_siso, True, True, None, None, None, True), + (sys_mimo, True, True, 'row', 'row', 'col', False), + (sys_mimo, True, True, 'row', 'row', 'col', True), + (sys_test, True, True, 'row', 'row', 'col', False), + (sys_test, True, True, 'row', 'row', 'col', True), + (sys_test, True, True, 'none', 'none', 'col', True), + (sys_test, True, True, 'all', 'row', 'col', False), + (sys_test, True, True, 'row', 'all', 'col', True), + (sys_test, True, True, None, 'row', 'col', False), + (sys_test, True, True, 'row', None, 'col', True), + ] + for args in test_cases: + test_response_plots(*args, clear=False) + # Define and run a selected set of interesting tests # TODO: TBD (see timeplot_test.py for format) test_basic_freq_plots(savefigs=True) + test_gangof4_plots(savefigs=True) # # Run a few more special cases to show off capabilities (and save some diff --git a/control/tests/freqresp_test.py b/control/tests/freqresp_test.py index 746e694be..f788e23ea 100644 --- a/control/tests/freqresp_test.py +++ b/control/tests/freqresp_test.py @@ -40,16 +40,26 @@ def ss_mimo(): return StateSpace(A, B, C, D) +@pytest.mark.filterwarnings("ignore:freqresp is deprecated") +def test_freqresp_siso_legacy(ss_siso): + """Test SISO frequency response""" + omega = np.linspace(10e-2, 10e2, 1000) + + # test frequency response + ctrl.frequency_response(ss_siso, omega) + + def test_freqresp_siso(ss_siso): """Test SISO frequency response""" omega = np.linspace(10e-2, 10e2, 1000) # test frequency response - ctrl.freqresp(ss_siso, omega) + ctrl.frequency_response(ss_siso, omega) +@pytest.mark.filterwarnings("ignore:freqresp is deprecated") @slycotonly -def test_freqresp_mimo(ss_mimo): +def test_freqresp_mimo_legacy(ss_mimo): """Test MIMO frequency response calls""" omega = np.linspace(10e-2, 10e2, 1000) ctrl.freqresp(ss_mimo, omega) @@ -57,6 +67,16 @@ def test_freqresp_mimo(ss_mimo): ctrl.freqresp(tf_mimo, omega) +@slycotonly +def test_freqresp_mimo(ss_mimo): + """Test MIMO frequency response calls""" + omega = np.linspace(10e-2, 10e2, 1000) + ctrl.frequency_response(ss_mimo, omega) + tf_mimo = tf(ss_mimo) + ctrl.frequency_response(tf_mimo, omega) + + +@pytest.mark.usefixtures("legacy_plot_signature") def test_bode_basic(ss_siso): """Test bode plot call (Very basic)""" # TODO: proper test @@ -92,6 +112,7 @@ def test_nyquist_basic(ss_siso): assert len(contour) == 10 +@pytest.mark.usefixtures("legacy_plot_signature") @pytest.mark.filterwarnings("ignore:.*non-positive left xlim:UserWarning") def test_superimpose(): """Test superimpose multiple calls. @@ -144,6 +165,7 @@ def test_superimpose(): assert len(ax.get_lines()) == 2 +@pytest.mark.usefixtures("legacy_plot_signature") def test_doubleint(): """Test typcast bug with double int @@ -157,6 +179,7 @@ def test_doubleint(): bode(sys) +@pytest.mark.usefixtures("legacy_plot_signature") @pytest.mark.parametrize( "Hz, Wcp, Wcg", [pytest.param(False, 6.0782869, 10., id="omega"), @@ -241,6 +264,7 @@ def dsystem_type(request, dsystem_dt): return dsystem_dt[systype] +@pytest.mark.usefixtures("legacy_plot_signature") @pytest.mark.parametrize("dsystem_dt", [0.1, True], indirect=True) @pytest.mark.parametrize("dsystem_type", ['sssiso', 'ssmimo', 'tf'], indirect=True) @@ -278,6 +302,7 @@ def test_discrete(dsystem_type): bode((dsys,)) +@pytest.mark.usefixtures("legacy_plot_signature") def test_options(editsdefaults): """Test ability to set parameter values""" # Generate a Bode plot of a transfer function @@ -310,6 +335,7 @@ def test_options(editsdefaults): assert numpoints1 != numpoints3 assert numpoints3 == 13 +@pytest.mark.usefixtures("legacy_plot_signature") @pytest.mark.parametrize( "TF, initial_phase, default_phase, expected_phase", [pytest.param(ctrl.tf([1], [1, 0]), @@ -349,6 +375,7 @@ def test_initial_phase(TF, initial_phase, default_phase, expected_phase): assert(abs(phase[0] - expected_phase) < 0.1) +@pytest.mark.usefixtures("legacy_plot_signature") @pytest.mark.parametrize( "TF, wrap_phase, min_phase, max_phase", [pytest.param(ctrl.tf([1], [1, 0]), @@ -376,6 +403,7 @@ def test_phase_wrap(TF, wrap_phase, min_phase, max_phase): assert(max(phase) <= max_phase) +@pytest.mark.usefixtures("legacy_plot_signature") def test_phase_wrap_multiple_systems(): sys_unstable = ctrl.zpk([],[1,1], gain=1) diff --git a/control/tests/kwargs_test.py b/control/tests/kwargs_test.py index 2f111f590..ec20a5a1a 100644 --- a/control/tests/kwargs_test.py +++ b/control/tests/kwargs_test.py @@ -139,9 +139,7 @@ def test_unrecognized_kwargs(function, nsssys, ntfsys, moreargs, kwargs, @pytest.mark.parametrize( "function, nsysargs, moreargs, kwargs", - [(control.bode, 1, (), {}), - (control.bode_plot, 1, (), {}), - (control.describing_function_plot, 1, + [(control.describing_function_plot, 1, (control.descfcn.saturation_nonlinearity(1), [1, 2, 3, 4]), {}), (control.gangof4, 2, (), {}), (control.gangof4_plot, 2, (), {}), @@ -168,11 +166,18 @@ def test_matplotlib_kwargs(function, nsysargs, moreargs, kwargs, mplcleanup): (control.step_response, control.time_response_plot), (control.step_response, control.TimeResponseData.plot), (control.frequency_response, control.FrequencyResponseData.plot), + (control.frequency_response, control.bode), + (control.frequency_response, control.bode_plot), ]) def test_response_plot_kwargs(data_fcn, plot_fcn): # Create a system for testing response = data_fcn(control.rss(4, 2, 2)) + # Make sure that calling the data function with unknown keyword errs + with pytest.raises((AttributeError, TypeError), + match="(has no property|unexpected keyword)"): + data_fcn(control.rss(2, 1, 1), unknown=None) + # Call the plotting function normally and make sure it works plot_fcn(response) @@ -192,8 +197,8 @@ def test_response_plot_kwargs(data_fcn, plot_fcn): # kwarg_unittest = { - 'bode': test_matplotlib_kwargs, - 'bode_plot': test_matplotlib_kwargs, + 'bode': test_response_plot_kwargs, + 'bode_plot': test_response_plot_kwargs, 'create_estimator_iosystem': stochsys_test.test_estimator_errors, 'create_statefbk_iosystem': statefbk_test.TestStatefbk.test_statefbk_errors, 'describing_function_plot': test_matplotlib_kwargs, diff --git a/control/tests/slycot_convert_test.py b/control/tests/slycot_convert_test.py index edd355b3b..25beeb908 100644 --- a/control/tests/slycot_convert_test.py +++ b/control/tests/slycot_convert_test.py @@ -124,6 +124,7 @@ def testTF(self, states, outputs, inputs, testNum, verbose): # np.testing.assert_array_almost_equal( # tfOriginal_dcoeff, tfTransformed_dcoeff, decimal=3) + @pytest.mark.usefixtures("legacy_plot_signature") @pytest.mark.parametrize("testNum", np.arange(numTests) + 1) @pytest.mark.parametrize("inputs", np.arange(1) + 1) # SISO only @pytest.mark.parametrize("outputs", np.arange(1) + 1) # SISO only From 949dcafe3edbe4262ea7a4193d21e3144b115b94 Mon Sep 17 00:00:00 2001 From: Richard Murray Date: Thu, 13 Jul 2023 16:28:22 -0700 Subject: [PATCH 05/17] updated nyquist limit lines for dtime systems --- control/freqplot.py | 40 ++++++++++++++++++---------------------- 1 file changed, 18 insertions(+), 22 deletions(-) diff --git a/control/freqplot.py b/control/freqplot.py index 7cc79d5f1..f29e7ff48 100644 --- a/control/freqplot.py +++ b/control/freqplot.py @@ -20,6 +20,7 @@ # [ ] Add line labels to gangof4 # [ ] Update FRD to allow nyquist_response contours # [ ] Allow frequency range to be overridden in bode_plot +# [ ] Unit tests for discrete time systems with different sample times # # This file contains some standard control system plots: Bode plots, @@ -615,9 +616,6 @@ def _share_axes(ref, share_map, axis): phase = phase_data[index].reshape((noutputs, ninputs, -1)) omega_sys, sysname = response.omega, response.sysname - # Keep track of Nyquist frequency for discrete time systems - nyq_freq = None if response.isctime() else math.pi / response.dt - for i, j in itertools.product(range(noutputs), range(ninputs)): # Get the axes to use for magnitude and phase ax_mag = ax_array[mag_map[i, j]] @@ -625,8 +623,8 @@ def _share_axes(ref, share_map, axis): # Get the frequencies and convert to Hz, if needed omega_plot = omega_sys / (2 * math.pi) if Hz else omega_sys - if nyq_freq is not None and Hz: - nyq_freq = nyq_freq / (2 * math.pi) + if response.isdtime(strict=True): + nyq_freq = 0.5 /response.dt if Hz else math.pi / response.dt # Save the magnitude and phase to plot mag_plot = 20 * np.log10(mag[i, j]) if dB else mag[i, j] @@ -642,14 +640,13 @@ def _share_axes(ref, share_map, axis): omega_plot, mag_plot, *fmt, label=sysname, **kwargs) out[mag_map[i, j]] += lines - # Plot vertical line at Nyquist frequency - # TODO: move this until after all data are plotted - if nyq_freq: - pltfcn( - [nyq_freq, nyq_freq], ax_mag.get_ylim(), - color=lines[0].get_color(), linestyle='--') + # Save the information needed for the Nyquist line + if response.isdtime(strict=True): + ax_mag.axvline( + nyq_freq, color=lines[0].get_color(), linestyle='--', + label='_nyq_mag_' + sysname) - # Add a grid to the plot + labeling + # Add a grid to the plot + labeling (TODO? move to later?) ax_mag.grid(grid and not margins, which='both') # Phase @@ -658,12 +655,11 @@ def _share_axes(ref, share_map, axis): omega_plot, phase_plot, *fmt, label=sysname, **kwargs) out[phase_map[i, j]] += lines - # Plot vertical line at Nyquist frequency - # TODO: move this until after all data are plotted - if nyq_freq: - ax_phase.semilogx( - [nyq_freq, nyq_freq], ax_phase.get_ylim(), - color=lines[0].get_color(), linestyle='--') + # Save the information needed for the Nyquist line + if response.isdtime(strict=True): + ax_phase.axvline( + nyq_freq, color=lines[0].get_color(), linestyle='--', + label='_nyq_phase_' + sysname) # Add a grid to the plot + labeling ax_phase.grid(grid and not margins, which='both') @@ -1015,14 +1011,14 @@ def gen_zero_centered_series(val_min, val_max, period): for j in range(ncols): ax = ax_array[i, j] # Get the labels to use, removing common strings - labels = _make_legend_labels( - [line.get_label() for line in ax.get_lines() - if line.get_label()[0] != '_']) + lines = [line for line in ax.get_lines() + if line.get_label()[0] != '_'] + labels = _make_legend_labels([line.get_label() for line in lines]) # Generate the label, if needed if len(labels) > 1 and legend_map[i, j] != None: with plt.rc_context(freqplot_rcParams): - ax.legend(labels, loc=legend_map[i, j]) + ax.legend(lines, labels, loc=legend_map[i, j]) # # Legacy return pocessing From 4dbb0cb51ad02b67053e67937985fe0b6b5610e1 Mon Sep 17 00:00:00 2001 From: Richard Murray Date: Fri, 14 Jul 2023 18:33:14 -0700 Subject: [PATCH 06/17] refactor singular_values_plot into response/plot --- control/frdata.py | 6 +- control/freqplot.py | 512 ++++++++++++++++++++++------------ control/lti.py | 5 +- control/tests/config_test.py | 3 +- control/tests/nyquist_test.py | 4 +- 5 files changed, 340 insertions(+), 190 deletions(-) diff --git a/control/frdata.py b/control/frdata.py index e3b8a3a33..383529afb 100644 --- a/control/frdata.py +++ b/control/frdata.py @@ -218,6 +218,7 @@ def __init__(self, *args, **kwargs): self.return_magphase=kwargs.pop('return_magphase', False) if self.return_magphase not in (True, False): raise ValueError("unknown return_magphase value") + self._return_singvals=kwargs.pop('_return_singvals', False) # Determine whether to squeeze the output self.squeeze=kwargs.pop('squeeze', None) @@ -601,7 +602,10 @@ def __call__(self, s=None, squeeze=None, return_magphase=None): def __iter__(self): fresp = _process_frequency_response( self, self.omega, self.fresp, squeeze=self.squeeze) - if not self.return_magphase: + if self._return_singvals: + # Legacy processing for singular values + return iter((self.fresp[:, 0, :], self.omega)) + elif not self.return_magphase: return iter((self.omega, fresp)) return iter((np.abs(fresp), np.angle(fresp), self.omega)) diff --git a/control/freqplot.py b/control/freqplot.py index f29e7ff48..a4ea90d54 100644 --- a/control/freqplot.py +++ b/control/freqplot.py @@ -24,8 +24,9 @@ # # This file contains some standard control system plots: Bode plots, -# Nyquist plots and pole-zero diagrams. The code for Nichols charts -# is in nichols.py. +# Nyquist plots and other frequency response plots. The code for Nichols +# charts is in nichols.py. The code for pole-zero diagrams is in pzmap.py +# and rlocus.py. # # Copyright (c) 2010 by California Institute of Technology # All rights reserved. @@ -59,33 +60,29 @@ # OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF # SUCH DAMAGE. # -# $Id$ - -# TODO: clean up imports -import math -from os.path import commonprefix +import numpy as np import matplotlib as mpl import matplotlib.pyplot as plt -import numpy as np +import math import warnings -from math import nan import itertools +from os.path import commonprefix from .ctrlutil import unwrap from .bdalg import feedback from .margins import stability_margins from .exception import ControlMIMONotImplemented from .statesp import StateSpace -from .lti import frequency_response, _process_frequency_response +from .lti import LTI, frequency_response, _process_frequency_response from .xferfcn import TransferFunction from .frdata import FrequencyResponseData from .timeplot import _make_legend_labels from . import config -__all__ = ['bode_plot', 'nyquist_plot', 'singular_values_plot', - 'gangof4_plot', 'gangof4_response', 'bode', 'nyquist', - 'gangof4'] +__all__ = ['bode_plot', 'nyquist_plot', 'singular_values_response', + 'singular_values_plot', 'gangof4_plot', 'gangof4_response', + 'bode', 'nyquist', 'gangof4'] # Default font dictionary _freqplot_rcParams = mpl.rcParams.copy() @@ -112,23 +109,8 @@ 'freqplot.share_magnitude': 'row', 'freqplot.share_phase': 'row', 'freqplot.share_frequency': 'col', - - # deprecations - 'deprecated.bode.dB': 'freqplot.dB', - 'deprecated.bode.deg': 'freqplot.deg', - 'deprecated.bode.Hz': 'freqplot.Hz', - 'deprecated.bode.grid': 'freqplot.grid', - 'deprecated.bode.wrap_phase': 'freqplot.wrap_phase', } - -# -# Main plotting functions -# -# This section of the code contains the functions for generating -# frequency domain plots -# - # # Bode plot # @@ -136,6 +118,8 @@ def bode_plot( data, omega=None, *fmt, ax=None, omega_limits=None, omega_num=None, plot=None, plot_magnitude=True, plot_phase=None, margins=None, + overlay_outputs=None, overlay_inputs=None, phase_label=None, + magnitude_label=None, margin_info=False, method='best', legend_map=None, legend_loc=None, sharex=None, sharey=None, title=None, relabel=True, **kwargs): """Bode plot for a system. @@ -165,6 +149,7 @@ def bode_plot( Method to use in computing margins (see :func:`stability_margins`). *fmt : :func:`matplotlib.pyplot.plot` format string, optional Passed to `matplotlib` as the format string for all lines in the plot. + The `omega` parameter must be present (use omega=None if needed). **kwargs : :func:`matplotlib.pyplot.plot` keyword properties, optional Additional keywords passed to `matplotlib` to specify line properties. @@ -175,9 +160,9 @@ def bode_plot( the array matches the subplots shape and the value of the array is a list of Line2D objects in that subplot. mag : ndarray (or list of ndarray if len(data) > 1)) - If plot=False, magnitude of the respone (deprecated). + If plot=False, magnitude of the response (deprecated). phase : ndarray (or list of ndarray if len(data) > 1)) - If plot=False, phase in radians of the respone (deprecated). + If plot=False, phase in radians of the response (deprecated). omega : ndarray (or list of ndarray if len(data) > 1)) If plot=False, frequency in rad/sec (deprecated). @@ -261,8 +246,14 @@ def bode_plot( omega_num = config._get_param('freqplot', 'number_of_samples', omega_num) freqplot_rcParams = config._get_param( 'freqplot', 'rcParams', kwargs, _freqplot_defaults, pop=True) + + # Set the default labels freq_label = config._get_param( 'freqplot', 'freq_label', kwargs, _freqplot_defaults, pop=True) + if magnitude_label is None: + magnitude_label = "Magnitude [dB]" if dB else "Magnitude" + if phase_label is None: + phase_label = "Phase [deg]" if deg else "Phase [rad]" # Use sharex and sharey as proxies for share_{magnitude, phase, frequency} if sharey is not None: @@ -454,9 +445,20 @@ def bode_plot( noutputs = max(noutputs, response.noutputs) # Figure how how many rows and columns to use + offsets for inputs/outputs - nrows = (noutputs if plot_magnitude else 0) + \ - (noutputs if plot_phase else 0) - ncols = ninputs + if overlay_outputs and overlay_inputs: + nrows = plot_magnitude + plot_phase + ncols = 1 + elif overlay_outputs: + nrows = plot_magnitude + plot_phase + ncols = ninputs + elif overlay_inputs: + nrows = (noutputs if plot_magnitude else 0) + \ + (noutputs if plot_phase else 0) + ncols = 1 + else: + nrows = (noutputs if plot_magnitude else 0) + \ + (noutputs if plot_phase else 0) + ncols = ninputs # See if we can use the current figure axes fig = plt.gcf() # get current figure (or create new one) @@ -510,7 +512,14 @@ def bode_plot( mag_map = np.empty((noutputs, ninputs), dtype=tuple) for i in range(noutputs): for j in range(ninputs): - mag_map[i, j] = (i, j) + if overlay_outputs and overlay_inputs: + mag_map[i, j] = (0, 0) + elif overlay_outputs: + mag_map[i, j] = (0, j) + elif overlay_inputs: + mag_map[i, j] = (i, 0) + else: + mag_map[i, j] = (i, j) phase_map = np.full((noutputs, ninputs), None) share_phase = False @@ -518,7 +527,14 @@ def bode_plot( phase_map = np.empty((noutputs, ninputs), dtype=tuple) for i in range(noutputs): for j in range(ninputs): - phase_map[i, j] = (i, j) + if overlay_outputs and overlay_inputs: + phase_map[i, j] = (0, 0) + elif overlay_outputs: + phase_map[i, j] = (0, j) + elif overlay_inputs: + phase_map[i, j] = (i, 0) + else: + phase_map[i, j] = (i, j) mag_map = np.full((noutputs, ninputs), None) share_magnitude = False @@ -527,8 +543,18 @@ def bode_plot( phase_map = np.empty((noutputs, ninputs), dtype=tuple) for i in range(noutputs): for j in range(ninputs): - mag_map[i, j] = (i*2, j) - phase_map[i, j] = (i*2 + 1, j) + if overlay_outputs and overlay_inputs: + mag_map[i, j] = (0, 0) + phase_map[i, j] = (1, 0) + elif overlay_outputs: + mag_map[i, j] = (0, j) + phase_map[i, j] = (1, j) + elif overlay_inputs: + mag_map[i, j] = (i*2, 0) + phase_map[i, j] = (i*2 + 1, 0) + else: + mag_map[i, j] = (i*2, j) + phase_map[i, j] = (i*2 + 1, j) # Identity map needed for setting up shared axes ax_map = np.empty((nrows, ncols), dtype=tuple) @@ -563,18 +589,18 @@ def _share_axes(ref, share_map, axis): # Process magnitude, phase, and frequency axes for name, value, map, axis in zip( - ['share_magnitude', 'shape_phase', 'share_frequency'], + ['share_magnitude', 'share_phase', 'share_frequency'], [ share_magnitude, share_phase, share_frequency], [ mag_map, phase_map, ax_map], [ 'y', 'y', 'x']): if value in [True, 'all']: _share_axes(map[0 if axis == 'y' else -1, 0], map, axis) elif axis == 'y' and value in ['row']: - for i in range(noutputs): + for i in range(noutputs if not overlay_outputs else 1): _share_axes(map[i, 0], map[i], 'y') elif axis == 'x' and value in ['col']: for j in range(ncols): - _share_axes(map[-1, j], map[j], 'x') + _share_axes(map[-1, j], map[:, j], 'x') elif value in [False, 'none']: # TODO: turn off any sharing that is on pass @@ -610,6 +636,26 @@ def _share_axes(ref, share_map, axis): for j in range(ncols): out[i, j] = [] # unique list in each element + # Utility function for creating line label + def _make_line_label(response, output_index, input_index): + label = "" # start with an empty label + + # Add the output name if it won't appear as an axes label + if noutputs > 1 and overlay_outputs: + label += response.output_labels[output_index] + + # Add the input name if it won't appear as a column label + if ninputs > 1 and overlay_inputs: + label += ", " if label != "" else "" + label += response.input_labels[input_index] + + # Add the system name (will strip off later if redundant) + label += ", " if label != "" else "" + label += f"{response.sysname}" + + print(label) + return label + for index, response in enumerate(data): # Get the (pre-processed) data in fully indexed form mag = mag_data[index].reshape((noutputs, ninputs, -1)) @@ -630,14 +676,16 @@ def _share_axes(ref, share_map, axis): mag_plot = 20 * np.log10(mag[i, j]) if dB else mag[i, j] phase_plot = phase[i, j] * 180. / math.pi if deg else phase[i, j] + # Generate a label + label = _make_line_label(response, i, j) + # Magnitude if plot_magnitude: pltfcn = ax_mag.semilogx if dB else ax_mag.loglog - convert = (lambda x: 20 * np.log10(x)) if dB else (lambda x: x) # Plot the main data lines = pltfcn( - omega_plot, mag_plot, *fmt, label=sysname, **kwargs) + omega_plot, mag_plot, *fmt, label=label, **kwargs) out[mag_map[i, j]] += lines # Save the information needed for the Nyquist line @@ -652,7 +700,7 @@ def _share_axes(ref, share_map, axis): # Phase if plot_phase: lines = ax_phase.semilogx( - omega_plot, phase_plot, *fmt, label=sysname, **kwargs) + omega_plot, phase_plot, *fmt, label=label, **kwargs) out[phase_map[i, j]] += lines # Save the information needed for the Nyquist line @@ -907,7 +955,7 @@ def gen_zero_centered_series(val_min, val_max, period): fig.suptitle(new_title) # - # Label the axes (including trace labels) + # Label the axes (including header labels) # # Once the data are plotted, we label the axes. The horizontal axes is # always frequency and this is labeled only on the bottom most row. The @@ -919,9 +967,10 @@ def gen_zero_centered_series(val_min, val_max, period): # # Label the columns (do this first to get row labels in the right spot) - for j in range(ninputs): + for j in range(ncols): # If we have more than one column, label the individual responses - if noutputs > 1 or ninputs > 1: + if (noutputs > 1 and not overlay_outputs or ninputs > 1) \ + and not overlay_inputs: with plt.rc_context(_freqplot_rcParams): ax_array[0, j].set_title(f"From {data[0].input_labels[j]}") @@ -929,15 +978,15 @@ def gen_zero_centered_series(val_min, val_max, period): ax_array[-1, j].set_xlabel(freq_label % ("Hz" if Hz else "rad/s",)) # Label the rows - for i in range(noutputs): + for i in range(noutputs if not overlay_outputs else 1): if plot_magnitude: - ax_mag = ax_array[i*2 if plot_phase else i, 0] - ax_mag.set_ylabel("Magnitude (dB)" if dB else "Magnitude") + ax_mag = ax_array[mag_map[i, 0]] + ax_mag.set_ylabel(magnitude_label) if plot_phase: - ax_phase = ax_array[i*2 + 1 if plot_magnitude else i, 0] - ax_phase.set_ylabel("Phase [deg]" if deg else "Phase [rad]") + ax_phase = ax_array[phase_map[i, 0]] + ax_phase.set_ylabel(phase_label) - if noutputs > 1 or ninputs > 1: + if (noutputs > 1 or ninputs > 1) and not overlay_outputs: if plot_magnitude and plot_phase: # Get existing ylabel for left column and add a blank line ax_mag.set_ylabel("\n" + ax_mag.get_ylabel()) @@ -1226,30 +1275,6 @@ def nyquist_plot( 2 """ - # Check to see if legacy 'Plot' keyword was used - if 'Plot' in kwargs: - warnings.warn("'Plot' keyword is deprecated in nyquist_plot; " - "use 'plot'", FutureWarning) - # Map 'Plot' keyword to 'plot' keyword - plot = kwargs.pop('Plot') - - # Check to see if legacy 'labelFreq' keyword was used - if 'labelFreq' in kwargs: - warnings.warn("'labelFreq' keyword is deprecated in nyquist_plot; " - "use 'label_freq'", FutureWarning) - # Map 'labelFreq' keyword to 'label_freq' keyword - label_freq = kwargs.pop('labelFreq') - - # Check to see if legacy 'arrow_width' or 'arrow_length' were used - if 'arrow_width' in kwargs or 'arrow_length' in kwargs: - warnings.warn( - "'arrow_width' and 'arrow_length' keywords are deprecated in " - "nyquist_plot; use `arrow_size` instead", FutureWarning) - kwargs['arrow_size'] = \ - (kwargs.get('arrow_width', 0) + kwargs.get('arrow_length', 0)) / 2 - kwargs.pop('arrow_width', False) - kwargs.pop('arrow_length', False) - # Get values for params (and pop from list to allow keyword use in plot) omega_num_given = omega_num is not None omega_num = config._get_param('freqplot', 'number_of_samples', omega_num) @@ -1839,47 +1864,36 @@ def gangof4_plot(P, C, omega=None, **kwargs): # # Singular values plot # -def singular_values_plot(syslist, omega=None, - plot=True, omega_limits=None, omega_num=None, - *args, **kwargs): - """Singular value plot for a system +def singular_values_response( + sys, omega=None, omega_limits=None, omega_num=None, Hz=False): + """Singular value response for a system. - Plots a singular value plot for the system over a (optional) frequency - range. + Computes the singular values for a system or list of systems over + a (optional) frequency range. Parameters ---------- - syslist : linsys + sys : (list of) LTI systems List of linear systems (single system is OK). omega : array_like List of frequencies in rad/sec to be used for frequency response. plot : bool If True (default), generate the singular values plot. omega_limits : array_like of two values - Limits of the frequency vector to generate. - If Hz=True the limits are in Hz otherwise in rad/s. + Limits of the frequency vector to generate. If Hz=True the + limits are in Hz otherwise in rad/s. omega_num : int Number of samples to plot. Default value (1000) set by config.defaults['freqplot.number_of_samples']. - dB : bool - If True, plot result in dB. Default value (False) set by - config.defaults['freqplot.dB']. Hz : bool - If True, plot frequency in Hz (omega must be provided in rad/sec). - Default value (False) set by config.defaults['freqplot.Hz'] + If True, assume frequencies are given in Hz. Default value + (False) set by config.defaults['freqplot.Hz'] Returns ------- - sigma : ndarray (or list of ndarray if len(syslist) > 1)) - singular values - omega : ndarray (or list of ndarray if len(syslist) > 1)) - frequency in rad/sec - - Other Parameters - ---------------- - grid : bool - If True, plot grid lines on gain and phase plots. Default is set by - `config.defaults['freqplot.grid']`. + response : FrequencyResponseData + Frequency response with the number of outputs equal to the + number of singular values in the response, and a single input. Examples -------- @@ -1887,119 +1901,251 @@ def singular_values_plot(syslist, omega=None, >>> den = [75, 1] >>> G = ct.tf([[[87.8], [-86.4]], [[108.2], [-109.6]]], ... [[den, den], [den, den]]) - >>> sigmas, omegas = ct.singular_values_plot(G, omega=omegas, plot=False) - - >>> sigmas, omegas = ct.singular_values_plot(G, 0.0, plot=False) + >>> response = ct.singular_values_response(G, omega=omegas) """ + # If argument was a singleton, turn it into a tuple + syslist = sys if isinstance(sys, (list, tuple)) else (sys,) + + if any([not isinstance(sys, LTI) for sys in syslist]): + ValueError("singular values can only be computed for LTI systems") + + # Compute the frequency responses for the systems + responses = frequency_response( + syslist, omega=omega, omega_limits=omega_limits, + omega_num=omega_num, Hz=Hz, squeeze=False) + + # Calculate the singular values for each system in the list + svd_responses = [] + for response in responses: + # Compute the singular values (permute indices to make things work) + fresp_permuted = response.fresp.transpose((2, 0, 1)) + sigma = np.linalg.svd(fresp_permuted, compute_uv=False).transpose() + sigma_fresp = sigma.reshape(sigma.shape[0], 1, sigma.shape[1]) + + # Save the singular values as an FRD object + svd_responses.append( + FrequencyResponseData( + sigma_fresp, response.omega, _return_singvals=True, + outputs=[f'$\\sigma_{k}$' for k in range(sigma.shape[0])], + inputs='inputs', dt=response.dt, plot_phase=False, + sysname=response.sysname, + title=f"Singular values for {response.sysname}")) + + # Return the responses in the same form that we received the systems + if isinstance(sys, (list, tuple)): + return svd_responses + else: + return svd_responses[0] - # Make a copy of the kwargs dictionary since we will modify it - kwargs = dict(kwargs) - # Get values for params (and pop from list to allow keyword use in plot) +def singular_values_plot( + data, omega=None, *fmt, plot=None, omega_limits=None, omega_num=None, + title=None, legend_loc='center right', **kwargs): + """Plot the singular values for a system. + + Plot the singular values for a system or list of systems. If + multiple systems are plotted, each system in the list is plotted + in a different color. + + Parameters + ---------- + data : list of `FrequencyResponseData` + List of :class:`FrequencyResponseData` objects. For backward + compatibility, a list of LTI systems can also be given. + omega : array_like + List of frequencies in rad/sec over to plot over. + dB : bool + If True, plot result in dB. Default is False. + Hz : bool + If True, plot frequency in Hz (omega must be provided in rad/sec). + Default value (False) set by config.defaults['freqplot.Hz']. + *fmt : :func:`matplotlib.pyplot.plot` format string, optional + Passed to `matplotlib` as the format string for all lines in the plot. + The `omega` parameter must be present (use omega=None if needed). + **kwargs : :func:`matplotlib.pyplot.plot` keyword properties, optional + Additional keywords passed to `matplotlib` to specify line properties. + + Returns + ------- + out : array of Line2D + 1-D array of Line2D objects. The size of the array matches + the number of systems and the value of the array is a list of + Line2D objects for that system. + mag : ndarray (or list of ndarray if len(data) > 1)) + If plot=False, magnitude of the response (deprecated). + phase : ndarray (or list of ndarray if len(data) > 1)) + If plot=False, phase in radians of the response (deprecated). + omega : ndarray (or list of ndarray if len(data) > 1)) + If plot=False, frequency in rad/sec (deprecated). + + """ + # If argument was a singleton, turn it into a tuple + data = data if isinstance(data, (list, tuple)) else (data,) + + # Keyword processing dB = config._get_param( 'freqplot', 'dB', kwargs, _freqplot_defaults, pop=True) Hz = config._get_param( 'freqplot', 'Hz', kwargs, _freqplot_defaults, pop=True) grid = config._get_param( 'freqplot', 'grid', kwargs, _freqplot_defaults, pop=True) - plot = config._get_param( - 'freqplot', 'plot', plot, True) omega_num = config._get_param('freqplot', 'number_of_samples', omega_num) + freqplot_rcParams = config._get_param( + 'freqplot', 'rcParams', kwargs, _freqplot_defaults, pop=True) - # If argument was a singleton, turn it into a tuple - if not isinstance(syslist, (list, tuple)): - syslist = (syslist,) + # Process legacy system arguments + if any([isinstance(response, (StateSpace, TransferFunction)) + for response in data]): + warnings.warn( + "passing systems to `singular_values_plot` is deprecated; " + "use `singular_values_response()`", DeprecationWarning) + responses = singular_values_response( + data, omega=omega, omega_limits=omega_limits, + omega_num=omega_num) + legacy_usage = True + else: + responses = data + legacy_usage = False - omega, omega_range_given = _determine_omega_vector( - syslist, omega, omega_limits, omega_num, Hz=Hz) + # Process (legacy) plot keyword + if plot is not None: + warnings.warn( + "`singular_values_plot` return values of sigma, omega is " + "deprecated; use singular_values_response()", DeprecationWarning) + legacy_usage = True + else: + plot = True - omega = np.atleast_1d(omega) + # Extract the data we need for plotting + sigmas = [np.real(response.fresp[:, 0, :]) for response in responses] + omegas = [response.omega for response in responses] - if plot: - fig = plt.gcf() - ax_sigma = None + # Legacy processing for no plotting case + if plot is False: + if len(data) == 1: + return sigmas[0], omegas[0] + else: + return sigmas, omegas - # Get the current axes if they already exist - for ax in fig.axes: - if ax.get_label() == 'control-sigma': - ax_sigma = ax + fig = plt.gcf() # get current figure (or create new one) + ax_sigma = None # axes for plotting singular values - # If no axes present, create them from scratch - if ax_sigma is None: - plt.clf() - ax_sigma = plt.subplot(111, label='control-sigma') + # Get the current axes if they already exist + for ax in fig.axes: + if ax.get_label() == 'control-sigma': + ax_sigma = ax - # color cycle handled manually as all singular values - # of the same systems are expected to be of the same color - color_cycle = plt.rcParams['axes.prop_cycle'].by_key()['color'] - color_offset = 0 - if len(ax_sigma.lines) > 0: - last_color = ax_sigma.lines[-1].get_color() - if last_color in color_cycle: - color_offset = color_cycle.index(last_color) + 1 - - sigmas, omegas, nyquistfrqs = [], [], [] - for idx_sys, sys in enumerate(syslist): - 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)) + # If no axes present, create them from scratch + if ax_sigma is None: + if len(fig.axes) > 0: + # Create a new figure to avoid overwriting in the old one + fig = plt.figure() - omega_complex = np.exp(1j * omega_sys * sys.dt) - else: - nyquistfrq = None - omega_complex = 1j*omega_sys + with plt.rc_context(_freqplot_rcParams): + ax_sigma = plt.subplot(111, label='control-sigma') - fresp = sys(omega_complex, squeeze=False) + # Handle color cycle manually as all singular values + # of the same systems are expected to be of the same color + color_cycle = plt.rcParams['axes.prop_cycle'].by_key()['color'] + color_offset = 0 + if len(ax_sigma.lines) > 0: + last_color = ax_sigma.lines[-1].get_color() + if last_color in color_cycle: + color_offset = color_cycle.index(last_color) + 1 - fresp = fresp.transpose((2, 0, 1)) - sigma = np.linalg.svd(fresp, compute_uv=False) + # Create a list of lines for the output + out = np.empty(len(data), dtype=object) - sigmas.append(sigma.transpose()) # return shape is "channel first" - omegas.append(omega_sys) - nyquistfrqs.append(nyquistfrq) + for idx_sys, response in enumerate(responses): + sigma = sigmas[idx_sys].transpose() # frequency first for plotting + omega_sys = omegas[idx_sys] + if response.isdtime(strict=True): + nyquistfrq = math.pi / response.dt + else: + nyquistfrq = None - if plot: - color = color_cycle[(idx_sys + color_offset) % len(color_cycle)] - color = kwargs.pop('color', color) + color = color_cycle[(idx_sys + color_offset) % len(color_cycle)] + color = kwargs.pop('color', color) - # TODO: copy from above - nyquistfrq_plot = None - if Hz: - omega_plot = omega_sys / (2. * math.pi) - if nyquistfrq: - nyquistfrq_plot = nyquistfrq / (2. * math.pi) - else: - omega_plot = omega_sys - if nyquistfrq: - nyquistfrq_plot = nyquistfrq - sigma_plot = sigma - - if dB: - ax_sigma.semilogx(omega_plot, 20 * np.log10(sigma_plot), - color=color, *args, **kwargs) - else: - ax_sigma.loglog(omega_plot, sigma_plot, - color=color, *args, **kwargs) + # TODO: copy from above + nyquistfrq_plot = None + if Hz: + omega_plot = omega_sys / (2. * math.pi) + if nyquistfrq: + nyquistfrq_plot = nyquistfrq / (2. * math.pi) + else: + omega_plot = omega_sys + if nyquistfrq: + nyquistfrq_plot = nyquistfrq + sigma_plot = sigma + + # Decide on the system name + sysname = response.sysname if response.sysname is not None \ + else f"Unknown-{idx_sys}" + + if dB: + with plt.rc_context(freqplot_rcParams): + out[idx_sys] = ax_sigma.semilogx( + omega_plot, 20 * np.log10(sigma_plot), color=color, + label=sysname, *fmt, **kwargs) + else: + with plt.rc_context(freqplot_rcParams): + out[idx_sys] = ax_sigma.loglog( + omega_plot, sigma_plot, color=color, label=sysname, + *fmt, **kwargs) - if nyquistfrq_plot is not None: - ax_sigma.axvline(x=nyquistfrq_plot, color=color) + if nyquistfrq_plot is not None: + ax_sigma.axvline( + nyquistfrq_plot, color=color, linestyle='--', + label='_nyq_freq_' + sysname) # Add a grid to the plot + labeling - if plot: + if grid: ax_sigma.grid(grid, which='both') + with plt.rc_context(freqplot_rcParams): ax_sigma.set_ylabel( - "Singular Values (dB)" if dB else "Singular Values") - ax_sigma.set_xlabel("Frequency (Hz)" if Hz else "Frequency (rad/sec)") + "Singular Values [dB]" if dB else "Singular Values") + ax_sigma.set_xlabel("Frequency [Hz]" if Hz else "Frequency [rad/sec]") + + # List of systems that are included in this plot + labels, lines = [], [] + last_color, counter = None, 0 # label unknown systems + for i, line in enumerate(ax_sigma.get_lines()): + label = line.get_label() + if label.startswith("Unknown"): + label = f"Unknown-{counter}" + if last_color is None: + last_color = line.get_color() + elif last_color != line.get_color(): + counter += 1 + last_color = line.get_color() + elif label[0] == '_': + continue + + if label not in labels: + lines.append(line) + labels.append(label) + + # Add legend if there is more than one system plotted + if len(labels) > 1: + with plt.rc_context(freqplot_rcParams): + ax_sigma.legend(lines, labels, loc=legend_loc) + + # Add the title + if title is None: + title = "Singular values for " + ", ".join(labels) + with plt.rc_context(freqplot_rcParams): + fig.suptitle(title) + + if legacy_usage: + if len(responses) == 1: + return sigmas[0], omegas[0] + else: + return sigmas, omegas + + return out - if len(syslist) == 1: - return sigmas[0], omegas[0] - else: - return sigmas, omegas # # Utility functions # diff --git a/control/lti.py b/control/lti.py index 62eabd69d..da1e1826f 100644 --- a/control/lti.py +++ b/control/lti.py @@ -372,7 +372,8 @@ def evalfr(sys, x, squeeze=None): def frequency_response( - sys, omega=None, omega_limits=None, omega_num=None, squeeze=None): + sys, omega=None, omega_limits=None, omega_num=None, + Hz=None, squeeze=None): """Frequency response of an LTI system at multiple angular frequencies. In general the system may be multiple input, multiple output (MIMO), where @@ -453,7 +454,7 @@ def frequency_response( # Get the common set of frequencies to use omega_syslist, omega_range_given = _determine_omega_vector( - syslist, omega, omega_limits, omega_num) + syslist, omega, omega_limits, omega_num, Hz=Hz) responses = [] for sys_ in syslist: diff --git a/control/tests/config_test.py b/control/tests/config_test.py index 48737eaa8..ce68f5901 100644 --- a/control/tests/config_test.py +++ b/control/tests/config_test.py @@ -84,8 +84,7 @@ def test_default_deprecation(self): # assert that reset defaults keeps the custom type ct.config.reset_defaults() - with pytest.warns(FutureWarning, - match='bode.* has been renamed to.*freqplot'): + with pytest.raises(KeyError): assert ct.config.defaults['bode.Hz'] \ == ct.config.defaults['freqplot.Hz'] diff --git a/control/tests/nyquist_test.py b/control/tests/nyquist_test.py index ca3c813a3..773e7e943 100644 --- a/control/tests/nyquist_test.py +++ b/control/tests/nyquist_test.py @@ -317,9 +317,9 @@ def test_nyquist_exceptions(): match="only supports SISO"): ct.nyquist_plot(sys) - # Legacy keywords for arrow size + # Legacy keywords for arrow size (no longer supported) sys = ct.rss(2, 1, 1) - with pytest.warns(FutureWarning, match="use `arrow_size` instead"): + with pytest.raises(AttributeError): ct.nyquist_plot(sys, arrow_width=8, arrow_length=6) # Unknown arrow keyword From 2d6a779247d5a19e2f384c1680014b5b3646603c Mon Sep 17 00:00:00 2001 From: Richard Murray Date: Sat, 15 Jul 2023 18:36:37 -0700 Subject: [PATCH 07/17] implement FrequencyResponseList class with plot() method --- control/frdata.py | 5 +++-- control/freqplot.py | 44 ++++++++++++++++++++++++++++++++++++++++---- control/lti.py | 12 ++++++++---- 3 files changed, 51 insertions(+), 10 deletions(-) diff --git a/control/frdata.py b/control/frdata.py index 383529afb..91e6aa683 100644 --- a/control/frdata.py +++ b/control/frdata.py @@ -211,8 +211,9 @@ def __init__(self, *args, **kwargs): self.sysname = kwargs.pop('sysname', None) # Keep track of default properties for plotting - self.plot_phase=kwargs.pop('plot_phase', None) - self.title=kwargs.pop('title', None) + self.plot_phase = kwargs.pop('plot_phase', None) + self.title = kwargs.pop('title', None) + self.plot_type = kwargs.pop('plot_type', 'bode') # Keep track of return type self.return_magphase=kwargs.pop('return_magphase', False) diff --git a/control/freqplot.py b/control/freqplot.py index a4ea90d54..c2e57ef87 100644 --- a/control/freqplot.py +++ b/control/freqplot.py @@ -111,9 +111,41 @@ 'freqplot.share_frequency': 'col', } +# +# Frequency response data list class +# +# This class is a subclass of list that adds a plot() method, enabling +# direct plotting from routines returning a list of FrequencyResponseData +# objects. +# + +class FrequencyResponseList(list): + def plot(self, *args, plot_type=None, **kwargs): + if plot_type == None: + for response in self: + if plot_type is not None and response.plot_type != plot_type: + raise TypeError( + "inconsistent plot_types in data; set plot_type " + "to 'bode', 'svplot', or 'nyquist'") + plot_type = response.plot_type + + if plot_type == 'bode': + bode_plot(self, *args, **kwargs) + elif plot_type == 'svplot': + singular_values_plot(self, *args, **kwargs) + elif plot_type == 'nyquist': + # nyquist_plot(self, *args, **kwargs) + raise NotImplementedError("Nyquist plots not yet supported") + else: + raise ValueError(f"unknown plot type '{plot_type}'") + # # Bode plot # +# This is the default method for plotting frequency responses. There are +# lots of options available for tuning the format of the plot, (hopefully) +# covering most of the common use cases. +# def bode_plot( data, omega=None, *fmt, ax=None, omega_limits=None, omega_num=None, @@ -653,7 +685,6 @@ def _make_line_label(response, output_index, input_index): label += ", " if label != "" else "" label += f"{response.sysname}" - print(label) return label for index, response in enumerate(data): @@ -1927,14 +1958,14 @@ def singular_values_response( svd_responses.append( FrequencyResponseData( sigma_fresp, response.omega, _return_singvals=True, - outputs=[f'$\\sigma_{k}$' for k in range(sigma.shape[0])], + outputs=[f'$\\sigma_{{{k+1}}}$' for k in range(sigma.shape[0])], inputs='inputs', dt=response.dt, plot_phase=False, - sysname=response.sysname, + sysname=response.sysname, plot_type='svplot', title=f"Singular values for {response.sysname}")) # Return the responses in the same form that we received the systems if isinstance(sys, (list, tuple)): - return svd_responses + return FrequencyResponseList(svd_responses) else: return svd_responses[0] @@ -2017,6 +2048,11 @@ def singular_values_plot( else: plot = True + # Warn the user if we got past something that is not real-valued + if any([not np.allclose(np.imag(response.fresp[:, 0, :]), 0) + for response in responses]): + warnings.warn("data has non-zero imaginary component") + # Extract the data we need for plotting sigmas = [np.real(response.fresp[:, 0, :]) for response in responses] omegas = [response.omega for response in responses] diff --git a/control/lti.py b/control/lti.py index da1e1826f..34de8df88 100644 --- a/control/lti.py +++ b/control/lti.py @@ -119,8 +119,8 @@ def frequency_response(self, omega=None, squeeze=None): # Return the data as a frequency response data object response = self(s) return FrequencyResponseData( - response, omega, return_magphase=True, squeeze=squeeze, dt=self.dt, - sysname=self.name) + response, omega, return_magphase=True, squeeze=squeeze, + dt=self.dt, sysname=self.name, plot_type='bode') def dcgain(self): """Return the zero-frequency gain""" @@ -470,8 +470,12 @@ def frequency_response( # Compute the frequency response responses.append(sys_.frequency_response(omega_sys, squeeze=squeeze)) - - return responses if isinstance(sys, (list, tuple)) else responses[0] + + if isinstance(sys, (list, tuple)): + from .freqplot import FrequencyResponseList + return FrequencyResponseList(responses) + else: + return responses[0] # Alternative name (legacy) def freqresp(sys, omega): From 6ce29da9bf8ab46089b9f7e2d4ccfe39f65522c0 Mon Sep 17 00:00:00 2001 From: Richard Murray Date: Sat, 15 Jul 2023 22:16:18 -0700 Subject: [PATCH 08/17] TMP: update margins processing (display_margins) --- control/config.py | 4 +- control/freqplot.py | 138 ++++++++++++++++----------------- control/sisotool.py | 3 +- control/tests/freqresp_test.py | 17 ++-- 4 files changed, 80 insertions(+), 82 deletions(-) diff --git a/control/config.py b/control/config.py index 1ed8b5dd5..59f0e4825 100644 --- a/control/config.py +++ b/control/config.py @@ -326,13 +326,13 @@ def use_legacy_defaults(version): # # Use this function to handle a legacy keyword that has been renamed. This # function pops the old keyword off of the kwargs dictionary and issues a -# warning. if both the old and new keyword are present, a ControlArgument +# warning. If both the old and new keyword are present, a ControlArgument # exception is raised. # def _process_legacy_keyword(kwargs, oldkey, newkey, newval): if kwargs.get(oldkey) is not None: warnings.warn( - f"keyworld '{oldkey}' is deprecated; use '{newkey}'", + f"keyword '{oldkey}' is deprecated; use '{newkey}'", DeprecationWarning) if newval is not None: raise ControlArgument( diff --git a/control/freqplot.py b/control/freqplot.py index c2e57ef87..08f4729e7 100644 --- a/control/freqplot.py +++ b/control/freqplot.py @@ -149,10 +149,10 @@ def plot(self, *args, plot_type=None, **kwargs): def bode_plot( data, omega=None, *fmt, ax=None, omega_limits=None, omega_num=None, - plot=None, plot_magnitude=True, plot_phase=None, margins=None, + plot=None, plot_magnitude=True, plot_phase=None, overlay_outputs=None, overlay_inputs=None, phase_label=None, - magnitude_label=None, - margin_info=False, method='best', legend_map=None, legend_loc=None, + magnitude_label=None, display_margins=None, + margins_method='best', legend_map=None, legend_loc=None, sharex=None, sharey=None, title=None, relabel=True, **kwargs): """Bode plot for a system. @@ -173,11 +173,12 @@ def bode_plot( deg : bool If True, plot phase in degrees (else radians). Default value (True) set by config.defaults['freqplot.deg']. - margins : bool - If True, plot gain and phase margin. (TODO: merge with margin_info) - margin_info : bool - If True, plot information about gain and phase margin. - method : str, optional + display_margins : bool or str + If True, draw gain and phase margin lines on the magnitude and phase + graphs and display the margins at the top of the graph. If set to + 'overlay', the values for the gain and phase margin are placed on + the graph. Setting display_margins turns off the axes grid. + margins_method : str, optional Method to use in computing margins (see :func:`stability_margins`). *fmt : :func:`matplotlib.pyplot.plot` format string, optional Passed to `matplotlib` as the format string for all lines in the plot. @@ -269,8 +270,6 @@ def bode_plot( 'freqplot', 'Hz', kwargs, _freqplot_defaults, pop=True) grid = config._get_param( 'freqplot', 'grid', kwargs, _freqplot_defaults, pop=True) - margins = config._get_param( - 'freqplot', 'margins', margins, False) wrap_phase = config._get_param( 'freqplot', 'wrap_phase', kwargs, _freqplot_defaults, pop=True) initial_phase = config._get_param( @@ -300,6 +299,19 @@ def bode_plot( "sharex cannot be present with share_frequency") kwargs['share_frequency'] = sharex + # Legacy keywords for margins + display_margins = config._process_legacy_keyword( + kwargs, 'margins', 'display_margins', display_margins) + if kwargs.pop('margin_info', False): + warnings.warn( + "keyword 'margin_info' is deprecated; " + "use 'display_margins='overlay'") + if display_margins is False: + raise ValueError( + "conflicting_keywords: `display_margins` and `margin_info`") + margins_method = config._process_legacy_keyword( + kwargs, 'method', 'margins_method', margins_method) + if not isinstance(data, (list, tuple)): data = [data] @@ -725,8 +737,8 @@ def _make_line_label(response, output_index, input_index): nyq_freq, color=lines[0].get_color(), linestyle='--', label='_nyq_mag_' + sysname) - # Add a grid to the plot + labeling (TODO? move to later?) - ax_mag.grid(grid and not margins, which='both') + # Add a grid to the plot + ax_mag.grid(grid and not display_margins, which='both') # Phase if plot_phase: @@ -740,22 +752,22 @@ def _make_line_label(response, output_index, input_index): nyq_freq, color=lines[0].get_color(), linestyle='--', label='_nyq_phase_' + sysname) - # Add a grid to the plot + labeling - ax_phase.grid(grid and not margins, which='both') + # Add a grid to the plot + ax_phase.grid(grid and not display_margins, which='both') + print(f"phase_ylim={ax_phase.get_ylim()}") # - # Plot gain and phase margins (SISO only) + # Display gain and phase margins (SISO only) # - # Show the phase and gain margins in the plot - if margins: + if display_margins: if ninputs > 1 or noutputs > 1: raise NotImplementedError( "margins are not available for MIMO systems") # Compute stability margins for the system - margin = stability_margins(response, method=method) - gm, pm, Wcg, Wcp = (margin[i] for i in [0, 1, 3, 4]) + margins = stability_margins(response, method=margins_method) + gm, pm, Wcg, Wcp = (margins[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) @@ -780,69 +792,47 @@ def _make_line_label(response, output_index, input_index): math.radians(phase_limit), color='k', linestyle=':', zorder=-20) phase_ylim = ax_phase.get_ylim() + print(f"{phase_ylim=}") # Annotate the phase margin (if it exists) if plot_phase and pm != float('inf') and Wcp != float('nan'): - if dB: - ax_mag.semilogx( - [Wcp, Wcp], [0., -1e5], - color='k', linestyle=':', zorder=-20) - else: - ax_mag.loglog( - [Wcp, Wcp], [1., 1e-8], - color='k', linestyle=':', zorder=-20) + # Draw dotted lines marking the gain crossover frequencies + if plot_magnitude: + ax_mag.axvline(Wcp, color='k', linestyle=':', zorder=-30) + ax_phase.axvline(Wcp, color='k', linestyle=':', zorder=-30) + # Draw solid segments indicating the margins if deg: - ax_phase.semilogx( - [Wcp, Wcp], [1e5, phase_limit + pm], - color='k', linestyle=':', zorder=-20) ax_phase.semilogx( [Wcp, Wcp], [phase_limit + pm, phase_limit], color='k', zorder=-20) else: - ax_phase.semilogx( - [Wcp, Wcp], [1e5, math.radians(phase_limit) + - math.radians(pm)], - color='k', linestyle=':', zorder=-20) ax_phase.semilogx( [Wcp, Wcp], [math.radians(phase_limit) + math.radians(pm), math.radians(phase_limit)], color='k', zorder=-20) - ax_phase.set_ylim(phase_ylim) - # Annotate the gain margin (if it exists) if plot_magnitude and gm != float('inf') and \ Wcg != float('nan'): + # Draw dotted lines marking the phase crossover frequencies + ax_mag.axvline(Wcg, color='k', linestyle=':', zorder=-30) + if plot_phase: + ax_phase.axvline(Wcg, color='k', linestyle=':', zorder=-30) + + # Draw solid segments indicating the margins if dB: - ax_mag.semilogx( - [Wcg, Wcg], [-20.*np.log10(gm), -1e5], - color='k', linestyle=':', zorder=-20) ax_mag.semilogx( [Wcg, Wcg], [0, -20*np.log10(gm)], color='k', zorder=-20) else: - ax_mag.loglog( - [Wcg, Wcg], [1./gm, 1e-8], color='k', - linestyle=':', zorder=-20) ax_mag.loglog( [Wcg, Wcg], [1., 1./gm], color='k', zorder=-20) - if plot_phase: - if deg: - ax_phase.semilogx( - [Wcg, Wcg], [0, phase_limit], - color='k', linestyle=':', zorder=-20) - else: - ax_phase.semilogx( - [Wcg, Wcg], [0, math.radians(phase_limit)], - color='k', linestyle=':', zorder=-20) - - ax_mag.set_ylim(mag_ylim) - ax_phase.set_ylim(phase_ylim) - - if margin_info: + if display_margins == 'overlay': + # TODO: figure out how to handle case of multiple lines + # Put the margin information in the lower left corner if plot_magnitude: ax_mag.text( 0.04, 0.06, @@ -854,6 +844,7 @@ def _make_line_label(response, output_index, input_index): verticalalignment='bottom', transform=ax_mag.transAxes, fontsize=8 if int(mpl.__version__[0]) == 1 else 6) + if plot_phase: ax_phase.text( 0.04, 0.06, @@ -865,17 +856,24 @@ def _make_line_label(response, output_index, input_index): verticalalignment='bottom', transform=ax_phase.transAxes, fontsize=8 if int(mpl.__version__[0]) == 1 else 6) + else: - # TODO: gets overwritten below - plt.suptitle( - "Gm = %.2f %s(at %.2f %s), " - "Pm = %.2f %s (at %.2f %s)" % - (20*np.log10(gm) if dB else gm, - 'dB ' if dB else '', - Wcg, 'Hz' if Hz else 'rad/s', - pm if deg else math.radians(pm), - 'deg' if deg else 'rad', - Wcp, 'Hz' if Hz else 'rad/s')) + # Put the title underneath the suptitle (one line per system) + ax = ax_mag if ax_mag else ax_phase + axes_title = ax.get_title() + if axes_title is not None and axes_title != "": + axes_title += "\n" + with plt.rc_context(_freqplot_rcParams): + ax.set_title( + axes_title + f"{sysname}: " + "Gm = %.2f %s(at %.2f %s), " + "Pm = %.2f %s (at %.2f %s)" % + (20*np.log10(gm) if dB else gm, + 'dB ' if dB else '', + Wcg, 'Hz' if Hz else 'rad/s', + pm if deg else math.radians(pm), + 'deg' if deg else 'rad', + Wcp, 'Hz' if Hz else 'rad/s')) # # Finishing handling axes limit sharing @@ -887,12 +885,12 @@ def _make_line_label(response, output_index, input_index): # * manually generated labels and grids need to reflect the limts for # shared axes, which we don't know until we have plotted everything; # - # * the use of loglog and semilog regenerate the labels (not quite sure - # why, since using sharex and sharey in subplots does not have this - # behavior). + # * the loglog and semilog functions regenerate the labels (not quite + # sure why, since using sharex and sharey in subplots does not have + # this behavior). # # Note: as before, if the various share_* keywords are None then a - # previous set of axes are available and no updates are made. + # previous set of axes are available and no updates are made. (TODO: true?) # for i in range(noutputs): diff --git a/control/sisotool.py b/control/sisotool.py index a66160cef..f059c0af3 100644 --- a/control/sisotool.py +++ b/control/sisotool.py @@ -112,8 +112,7 @@ def sisotool(sys, initial_gain=None, xlim_rlocus=None, ylim_rlocus=None, 'omega_limits': omega_limits, 'omega_num' : omega_num, 'ax': axes[:, 0:1], - 'margins': margins_bode, - 'margin_info': True, + 'display_margins': 'overlay' if margins_bode else False, } # Check to see if legacy 'PrintGain' keyword was used diff --git a/control/tests/freqresp_test.py b/control/tests/freqresp_test.py index f788e23ea..e4d981fc1 100644 --- a/control/tests/freqresp_test.py +++ b/control/tests/freqresp_test.py @@ -204,27 +204,28 @@ def test_bode_margin(dB, maginfty1, maginfty2, gminv, fig = plt.gcf() allaxes = fig.get_axes() + # TODO: update with better tests for new margin plots mag_to_infinity = (np.array([Wcp, Wcp]), np.array([maginfty1, maginfty2])) - assert_allclose(mag_to_infinity, - allaxes[0].lines[2].get_data(), + assert_allclose(mag_to_infinity[0], + allaxes[0].lines[2].get_data()[0], rtol=1e-5) gm_to_infinty = (np.array([Wcg, Wcg]), np.array([gminv, maginfty2])) - assert_allclose(gm_to_infinty, - allaxes[0].lines[3].get_data(), + assert_allclose(gm_to_infinty[0], + allaxes[0].lines[3].get_data()[0], rtol=1e-5) one_to_gm = (np.array([Wcg, Wcg]), np.array([maginfty1, gminv])) - assert_allclose(one_to_gm, allaxes[0].lines[4].get_data(), + assert_allclose(one_to_gm[0], allaxes[0].lines[4].get_data()[0], rtol=1e-5) pm_to_infinity = (np.array([Wcp, Wcp]), np.array([1e5, pm])) - assert_allclose(pm_to_infinity, - allaxes[1].lines[2].get_data(), + assert_allclose(pm_to_infinity[0], + allaxes[1].lines[2].get_data()[0], rtol=1e-5) pm_to_phase = (np.array([Wcp, Wcp]), @@ -234,7 +235,7 @@ def test_bode_margin(dB, maginfty1, maginfty2, gminv, phase_to_infinity = (np.array([Wcg, Wcg]), np.array([0, p0])) - assert_allclose(phase_to_infinity, allaxes[1].lines[4].get_data(), + assert_allclose(phase_to_infinity[0], allaxes[1].lines[4].get_data()[0], rtol=1e-5) From d07422db2bfbd40103db94454d7dd457f3904714 Mon Sep 17 00:00:00 2001 From: Richard Murray Date: Sun, 16 Jul 2023 08:10:03 -0700 Subject: [PATCH 09/17] update plot handling to allow system arguments --- control/freqplot.py | 85 +++++++++++++++------------------- control/lti.py | 7 ++- control/matlab/wrappers.py | 9 ++-- control/tests/config_test.py | 20 +++++--- control/tests/discrete_test.py | 2 +- control/tests/freqresp_test.py | 9 ++-- 6 files changed, 68 insertions(+), 64 deletions(-) diff --git a/control/freqplot.py b/control/freqplot.py index 08f4729e7..90db65672 100644 --- a/control/freqplot.py +++ b/control/freqplot.py @@ -201,8 +201,10 @@ def bode_plot( Other Parameters ---------------- - plot : bool - If True (default), plot magnitude and phase. + plot : bool, optional + (legacy) If given, `bode_plot` returns the legacy return values + of magnitude, phase, and frequency. If False, just return the + values with no plot. 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. @@ -232,9 +234,13 @@ def bode_plot( Notes ----- - 1. Alternatively, you may use the lower-level methods - :meth:`LTI.frequency_response` or ``sys(s)`` or ``sys(z)`` or to - generate the frequency response for a single system. + 1. Starting with python-control version 0.10, `bode_plot`returns an + array of lines instead of magnitude, phase, and frequency. To + recover the # old behavior, call `bode_plot` with `plot=True`, which + will force the legacy return values to be used (with a warning). To + obtain just the frequency response of a system (or list of systems) + without plotting, use the :func:`~control.frequency_response` + command. 2. If a discrete time model is given, the frequency response is plotted along the upper branch of the unit circle, using the mapping ``z = @@ -242,12 +248,6 @@ def bode_plot( is the discrete timebase. If timebase not specified (``dt=True``), `dt` is set to 1. - 3. The legacy version of this function is invoked if instead of passing - frequency response data, a system (or list of systems) is passed as - the first argument, or if the (deprecated) keyword `plot` is set to - True or False. The return value is then given as `mag`, `phase`, - `omega` for the plotted frequency response (SISO only). - Examples -------- >>> G = ct.ss([[-1, -2], [3, -4]], [[5], [7]], [[6, 8]], [[9]]) @@ -315,18 +315,6 @@ def bode_plot( if not isinstance(data, (list, tuple)): data = [data] - # For backwards compatibility, allow systems in the data list - if all([isinstance( - sys, (StateSpace, TransferFunction)) for sys in data]): - data = frequency_response( - data, omega=omega, omega_limits=omega_limits, - omega_num=omega_num) - warnings.warn( - "passing systems to `bode_plot` is deprecated; " - "use `frequency_response()`", DeprecationWarning) - if plot is None: - plot = True # Keep track of legacy usage (see notes below) - # # Pre-process the data to be plotted (unwrap phase) # @@ -337,6 +325,13 @@ def bode_plot( # the list of lines created, which is the new output for _plot functions. # + # If we were passed a list of systems, convert to data + if all([isinstance( + sys, (StateSpace, TransferFunction)) for sys in data]): + data = frequency_response( + data, omega=omega, omega_limits=omega_limits, + omega_num=omega_num, Hz=Hz) + # If plot_phase is not specified, check the data first, otherwise true if plot_phase is None: plot_phase = True if data[0].plot_phase is None else data[0].plot_phase @@ -409,28 +404,25 @@ def bode_plot( # # There are three possibilities at this stage in the code: # - # * plot == True: either set explicitly by the user or we were passed a - # non-FRD system instead of data. Return mag, phase, omega, with a - # warning. + # * plot == True: set explicitly by the user. Return mag, phase, omega, + # with a warning. # # * plot == False: set explicitly by the user. Return mag, phase, # omega, with a warning. # - # * plot == None: this is the new default setting and if it hasn't been - # changed, then we use the v0.10+ standard of returning an array of + # * plot == None: this is the new default setting. Return an array of # lines that were drawn. # - # The one case that can cause problems is that a user called - # `bode_plot` with an FRD system, didn't set the plot keyword - # explicitly, and expected mag, phase, omega as a return value. This - # is hopefully a rare case (it wasn't in any of our unit tests nor - # examples at the time of v0.10.0). + # If `bode_plot` was called with no `plot` argument and the return + # values were used, the new code will cause problems (you get an array + # of lines instead of magnitude, phase, and frequency). To recover the + # old behavior, call `bode_plot` with `plot=True`. # # All of this should be removed in v0.11+ when we get rid of deprecated # code. # - if plot is True or plot is False: + if plot is not None: warnings.warn( "`bode_plot` return values of mag, phase, omega is deprecated; " "use frequency_response()", DeprecationWarning) @@ -1828,8 +1820,8 @@ def _compute_curve_offset(resp, mask, max_offset): def gangof4_response(P, C, omega=None, Hz=False): """Compute the response of the "Gang of 4" transfer functions for a system. - Generates a 2x2 plot showing the "Gang of 4" sensitivity functions - [T, PS; CS, S]. + Generates a 2x2 frequency response for the "Gang of 4" sensitivity + functions [T, PS; CS, S]. Parameters ---------- @@ -1840,7 +1832,9 @@ def gangof4_response(P, C, omega=None, Hz=False): Returns ------- - None + response : :class:`~control.FrequencyResponseData` + Frequency response with inputs 'r' and 'd' and outputs 'y', and 'u' + representing the 2x2 matrix of transfer functions in the Gang of 4. Examples -------- @@ -1989,6 +1983,10 @@ def singular_values_plot( Hz : bool If True, plot frequency in Hz (omega must be provided in rad/sec). Default value (False) set by config.defaults['freqplot.Hz']. + plot : bool, optional + (legacy) If given, `singular_values_plot` returns the legacy return + values of magnitude, phase, and frequency. If False, just return + the values with no plot. *fmt : :func:`matplotlib.pyplot.plot` format string, optional Passed to `matplotlib` as the format string for all lines in the plot. The `omega` parameter must be present (use omega=None if needed). @@ -2023,28 +2021,20 @@ def singular_values_plot( freqplot_rcParams = config._get_param( 'freqplot', 'rcParams', kwargs, _freqplot_defaults, pop=True) - # Process legacy system arguments + # Convert systems into frequency responses if any([isinstance(response, (StateSpace, TransferFunction)) for response in data]): - warnings.warn( - "passing systems to `singular_values_plot` is deprecated; " - "use `singular_values_response()`", DeprecationWarning) responses = singular_values_response( data, omega=omega, omega_limits=omega_limits, omega_num=omega_num) - legacy_usage = True else: responses = data - legacy_usage = False # Process (legacy) plot keyword if plot is not None: warnings.warn( "`singular_values_plot` return values of sigma, omega is " "deprecated; use singular_values_response()", DeprecationWarning) - legacy_usage = True - else: - plot = True # Warn the user if we got past something that is not real-valued if any([not np.allclose(np.imag(response.fresp[:, 0, :]), 0) @@ -2172,7 +2162,8 @@ def singular_values_plot( with plt.rc_context(freqplot_rcParams): fig.suptitle(title) - if legacy_usage: + # Legacy return processing + if plot is not None: if len(responses) == 1: return sigmas[0], omegas[0] else: diff --git a/control/lti.py b/control/lti.py index 34de8df88..14594f00f 100644 --- a/control/lti.py +++ b/control/lti.py @@ -424,8 +424,11 @@ def frequency_response( Notes ----- - This function is a wrapper for :meth:`StateSpace.frequency_response` and - :meth:`TransferFunction.frequency_response`. + 1. This function is a wrapper for :meth:`StateSpace.frequency_response` + and :meth:`TransferFunction.frequency_response`. + + 2. You can also use the lower-level methods ``sys(s)`` or ``sys(z)`` to + generate the frequency response for a single system. Examples -------- diff --git a/control/matlab/wrappers.py b/control/matlab/wrappers.py index 59c6cc7f3..5eb7786fe 100644 --- a/control/matlab/wrappers.py +++ b/control/matlab/wrappers.py @@ -64,11 +64,14 @@ def bode(*args, **kwargs): """ from ..freqplot import bode_plot + # Use the plot keyword to get legacy behavior + # TODO: update to call frequency_response and then bode_plot + kwargs = dict(kwargs) # make a copy since we modify this + if 'plot' not in kwargs: + kwargs['plot'] = True + # Turn off deprecation warning with warnings.catch_warnings(): - warnings.filterwarnings( - 'ignore', message='passing systems .* is deprecated', - category=DeprecationWarning) warnings.filterwarnings( 'ignore', message='.* return values of .* is deprecated', category=DeprecationWarning) diff --git a/control/tests/config_test.py b/control/tests/config_test.py index ce68f5901..3baff2b21 100644 --- a/control/tests/config_test.py +++ b/control/tests/config_test.py @@ -203,36 +203,42 @@ def test_custom_bode_default(self, mplcleanup): @pytest.mark.usefixtures("legacy_plot_signature") def test_bode_number_of_samples(self, mplcleanup): # Set the number of samples (default is 50, from np.logspace) - mag_ret, phase_ret, omega_ret = ct.bode_plot(self.sys, omega_num=87) + mag_ret, phase_ret, omega_ret = ct.bode_plot( + self.sys, omega_num=87, plot=True) assert len(mag_ret) == 87 # Change the default number of samples ct.config.defaults['freqplot.number_of_samples'] = 76 - mag_ret, phase_ret, omega_ret = ct.bode_plot(self.sys) + mag_ret, phase_ret, omega_ret = ct.bode_plot(self.sys, plot=True) assert len(mag_ret) == 76 # Override the default number of samples - mag_ret, phase_ret, omega_ret = ct.bode_plot(self.sys, omega_num=87) + mag_ret, phase_ret, omega_ret = ct.bode_plot( + self.sys, omega_num=87, plot=True) assert len(mag_ret) == 87 @pytest.mark.usefixtures("legacy_plot_signature") def test_bode_feature_periphery_decade(self, mplcleanup): # Generate a sample Bode plot to figure out the range it uses ct.reset_defaults() # Make sure starting state is correct - mag_ret, phase_ret, omega_ret = ct.bode_plot(self.sys, Hz=False) + mag_ret, phase_ret, omega_ret = ct.bode_plot( + self.sys, Hz=False, plot=True) omega_min, omega_max = omega_ret[[0, -1]] # Reset the periphery decade value (should add one decade on each end) ct.config.defaults['freqplot.feature_periphery_decades'] = 2 - mag_ret, phase_ret, omega_ret = ct.bode_plot(self.sys, Hz=False) + mag_ret, phase_ret, omega_ret = ct.bode_plot( + self.sys, Hz=False, plot=True) np.testing.assert_almost_equal(omega_ret[0], omega_min/10) np.testing.assert_almost_equal(omega_ret[-1], omega_max * 10) # Make sure it also works in rad/sec, in opposite direction - mag_ret, phase_ret, omega_ret = ct.bode_plot(self.sys, Hz=True) + mag_ret, phase_ret, omega_ret = ct.bode_plot( + self.sys, Hz=True, plot=True) omega_min, omega_max = omega_ret[[0, -1]] ct.config.defaults['freqplot.feature_periphery_decades'] = 1 - mag_ret, phase_ret, omega_ret = ct.bode_plot(self.sys, Hz=True) + mag_ret, phase_ret, omega_ret = ct.bode_plot( + self.sys, Hz=True, plot=True) np.testing.assert_almost_equal(omega_ret[0], omega_min*10) np.testing.assert_almost_equal(omega_ret[-1], omega_max/10) diff --git a/control/tests/discrete_test.py b/control/tests/discrete_test.py index e8a6b5199..96777011e 100644 --- a/control/tests/discrete_test.py +++ b/control/tests/discrete_test.py @@ -465,7 +465,7 @@ def test_discrete_bode(self, tsys): # Create a simple discrete time system and check the calculation sys = TransferFunction([1], [1, 0.5], 1) omega = [1, 2, 3] - mag_out, phase_out, omega_out = bode(sys, omega) + mag_out, phase_out, omega_out = bode(sys, omega, plot=True) H_z = list(map(lambda w: 1./(np.exp(1.j * w) + 0.5), omega)) np.testing.assert_array_almost_equal(omega, omega_out) np.testing.assert_array_almost_equal(mag_out, np.absolute(H_z)) diff --git a/control/tests/freqresp_test.py b/control/tests/freqresp_test.py index e4d981fc1..f452fe7df 100644 --- a/control/tests/freqresp_test.py +++ b/control/tests/freqresp_test.py @@ -360,11 +360,11 @@ def test_options(editsdefaults): ]) def test_initial_phase(TF, initial_phase, default_phase, expected_phase): # Check initial phase of standard transfer functions - mag, phase, omega = ctrl.bode(TF) + mag, phase, omega = ctrl.bode(TF, plot=True) assert(abs(phase[0] - default_phase) < 0.1) # Now reset the initial phase to +180 and see if things work - mag, phase, omega = ctrl.bode(TF, initial_phase=initial_phase) + mag, phase, omega = ctrl.bode(TF, initial_phase=initial_phase, plot=True) assert(abs(phase[0] - expected_phase) < 0.1) # Make sure everything works in rad/sec as well @@ -372,7 +372,8 @@ def test_initial_phase(TF, initial_phase, default_phase, expected_phase): plt.xscale('linear') # avoids xlim warning on next line plt.clf() # clear previous figure (speeds things up) mag, phase, omega = ctrl.bode( - TF, initial_phase=initial_phase/180. * math.pi, deg=False) + TF, initial_phase=initial_phase/180. * math.pi, + deg=False, plot=True) assert(abs(phase[0] - expected_phase) < 0.1) @@ -399,7 +400,7 @@ def test_initial_phase(TF, initial_phase, default_phase, expected_phase): -270, -3*math.pi/2, math.pi/2, id="order5, -270"), ]) def test_phase_wrap(TF, wrap_phase, min_phase, max_phase): - mag, phase, omega = ctrl.bode(TF, wrap_phase=wrap_phase) + mag, phase, omega = ctrl.bode(TF, wrap_phase=wrap_phase, plot=True) assert(min(phase) >= min_phase) assert(max(phase) <= max_phase) From e5360dc0d8073e75481b379ea7c44b6c9a9fb150 Mon Sep 17 00:00:00 2001 From: Richard Murray Date: Sun, 16 Jul 2023 13:43:03 -0700 Subject: [PATCH 10/17] refactoring of nyquist into response/plot + updated unit tests, examples --- control/ctrlutil.py | 13 +- control/descfcn.py | 11 +- control/freqplot.py | 748 +++++++++++++++++--------- control/lti.py | 8 +- control/matlab/wrappers.py | 10 +- control/tests/kwargs_test.py | 34 +- control/tests/nyquist_test.py | 150 +++--- examples/bode-and-nyquist-plots.ipynb | 22 +- examples/singular-values-plot.ipynb | 8 +- 9 files changed, 639 insertions(+), 365 deletions(-) diff --git a/control/ctrlutil.py b/control/ctrlutil.py index aeb0c30f1..6cd32593b 100644 --- a/control/ctrlutil.py +++ b/control/ctrlutil.py @@ -86,18 +86,9 @@ def unwrap(angle, period=2*math.pi): return angle def issys(obj): - """Return True if an object is a Linear Time Invariant (LTI) system, - otherwise False. + """Deprecated function to check if an object is an LTI system. - Examples - -------- - >>> G = ct.tf([1], [1, 1]) - >>> ct.issys(G) - True - - >>> K = np.array([[1, 1]]) - >>> ct.issys(K) - False + Use isinstance(obj, ct.LTI) """ warnings.warn("issys() is deprecated; use isinstance(obj, ct.LTI)", diff --git a/control/descfcn.py b/control/descfcn.py index 985046e19..a5cf0638d 100644 --- a/control/descfcn.py +++ b/control/descfcn.py @@ -18,7 +18,7 @@ import scipy from warnings import warn -from .freqplot import nyquist_plot +from .freqplot import nyquist_response __all__ = ['describing_function', 'describing_function_plot', 'DescribingFunctionNonlinearity', 'friction_backlash_nonlinearity', @@ -259,10 +259,11 @@ def describing_function_plot( warn = omega is None # Start by drawing a Nyquist curve - count, contour = nyquist_plot( - H, omega, plot=True, return_contour=True, - warn_encirclements=warn, warn_nyquist=warn, **kwargs) - H_omega, H_vals = contour.imag, H(contour) + response = nyquist_response( + H, omega, warn_encirclements=warn, warn_nyquist=warn, + check_kwargs=False, **kwargs) + response.plot(**kwargs) + H_omega, H_vals = response.contour.imag, H(response.contour) # Compute the describing function df = describing_function(F, A) diff --git a/control/freqplot.py b/control/freqplot.py index 90db65672..7762db052 100644 --- a/control/freqplot.py +++ b/control/freqplot.py @@ -14,13 +14,14 @@ # [i] Create __main__ in freqplot_test to view results (a la timeplot_test) # [ ] Get sisotool working in iPython and document how to make it work # [i] Allow share_magnitude, share_phase, share_frequency keywords for units -# [ ] Re-implement including of gain/phase margin in the title (?) +# [i] Re-implement including of gain/phase margin in the title (?) # [i] Change gangof4 to use bode_plot(plot_phase=False) w/ proper labels # [ ] Allow use of subplot labels instead of output/input subtitles # [ ] Add line labels to gangof4 # [ ] Update FRD to allow nyquist_response contours # [ ] Allow frequency range to be overridden in bode_plot # [ ] Unit tests for discrete time systems with different sample times +# [ ] Check examples/bode-and-nyquist-plots.ipynb for differences # # This file contains some standard control system plots: Bode plots, @@ -80,9 +81,10 @@ from .timeplot import _make_legend_labels from . import config -__all__ = ['bode_plot', 'nyquist_plot', 'singular_values_response', - 'singular_values_plot', 'gangof4_plot', 'gangof4_response', - 'bode', 'nyquist', 'gangof4'] +__all__ = ['bode_plot', 'nyquist_response', 'nyquist_plot', + 'singular_values_response', 'singular_values_plot', + 'gangof4_plot', 'gangof4_response', 'bode', 'nyquist', + 'gangof4'] # Default font dictionary _freqplot_rcParams = mpl.rcParams.copy() @@ -126,16 +128,13 @@ def plot(self, *args, plot_type=None, **kwargs): if plot_type is not None and response.plot_type != plot_type: raise TypeError( "inconsistent plot_types in data; set plot_type " - "to 'bode', 'svplot', or 'nyquist'") + "to 'bode' or 'svplot'") plot_type = response.plot_type if plot_type == 'bode': bode_plot(self, *args, **kwargs) elif plot_type == 'svplot': singular_values_plot(self, *args, **kwargs) - elif plot_type == 'nyquist': - # nyquist_plot(self, *args, **kwargs) - raise NotImplementedError("Nyquist plots not yet supported") else: raise ValueError(f"unknown plot type '{plot_type}'") @@ -251,7 +250,7 @@ def bode_plot( Examples -------- >>> G = ct.ss([[-1, -2], [3, -4]], [[5], [7]], [[6, 8]], [[9]]) - >>> Gmag, Gphase, Gomega = ct.bode_plot(G) + >>> out = ct.bode_plot(G) """ # @@ -746,7 +745,6 @@ def _make_line_label(response, output_index, input_index): # Add a grid to the plot ax_phase.grid(grid and not display_margins, which='both') - print(f"phase_ylim={ax_phase.get_ylim()}") # # Display gain and phase margins (SISO only) @@ -784,7 +782,6 @@ def _make_line_label(response, output_index, input_index): math.radians(phase_limit), color='k', linestyle=':', zorder=-20) phase_ylim = ax_phase.get_ylim() - print(f"{phase_ylim=}") # Annotate the phase margin (if it exists) if plot_phase and pm != float('inf') and Wcp != float('nan'): @@ -1130,19 +1127,55 @@ def gen_zero_centered_series(val_min, val_max, period): } -def nyquist_plot( - syslist, omega=None, plot=True, omega_limits=None, omega_num=None, - label_freq=0, color=None, return_contour=False, +class NyquistResponseData: + def __init__( + self, count, contour, response, dt, sysname=None, + return_contour=False): + self.count = count + self.contour = contour + self.response = response + self.dt = dt + self.sysname = sysname + self.return_contour = return_contour + + # Implement iter to allow assigning to a tuple + def __iter__(self): + if self.return_contour: + return iter((self.count, self.contour)) + else: + return iter((self.count, )) + + # Implement (thin) getitem to allow access via legacy indexing + def __getitem__(self, index): + return list(self.__iter__())[index] + + # Implement (thin) len to emulate legacy testing interface + def __len__(self): + return 2 if self.return_contour else 1 + + def plot(self, *args, **kwargs): + return nyquist_plot(self, *args, **kwargs) + + +class NyquistResponseList(list): + def plot(self, *args, **kwargs): + nyquist_plot(self, *args, **kwargs) + + +def nyquist_response( + syslist, omega=None, plot=None, omega_limits=None, omega_num=None, + label_freq=0, color=None, return_contour=False, check_kwargs=True, warn_encirclements=True, warn_nyquist=True, **kwargs): - """Nyquist plot for a system. + """Nyquist response for a system. - Plots a Nyquist plot for the system over a (optional) frequency range. - The curve is computed by evaluating the Nyqist segment along the positive - imaginary axis, with a mirror image generated to reflect the negative - imaginary axis. Poles on or near the imaginary axis are avoided using a - small indentation. The portion of the Nyquist contour at infinity is not - explicitly computed (since it maps to a constant value for any system with - a proper transfer function). + Computes a Nyquist contour for the system over a (optional) frequency + range and evaluates the number of net encirclements. The curve is + computed by evaluating the Nyqist segment along the positive imaginary + axis, with a mirror image generated to reflect the negative imaginary + axis. Poles on or near the imaginary axis are avoided using a small + indentation. The portion of the Nyquist contour at infinity is not + explicitly computed (since it maps to a constant value for any system + with a proper transfer function). Parameters ---------- @@ -1161,18 +1194,9 @@ def nyquist_plot( Number of frequency samples to plot. Defaults to config.defaults['freqplot.number_of_samples']. - plot : boolean, optional - If True (default), plot the Nyquist plot. - - color : string, optional - Used to specify the color of the line and arrowhead. - return_contour : bool, optional If 'True', return the contour used to evaluate the Nyquist plot. - **kwargs : :func:`matplotlib.pyplot.plot` keyword properties, optional - Additional keywords (passed to `matplotlib`) - Returns ------- count : int (or list of int if len(syslist) > 1) @@ -1186,23 +1210,6 @@ def nyquist_plot( Other Parameters ---------------- - arrows : int or 1D/2D array of floats, optional - Specify the number of arrows to plot on the Nyquist curve. If an - integer is passed. that number of equally spaced arrows will be - plotted on each of the primary segment and the mirror image. If a 1D - array is passed, it should consist of a sorted list of floats between - 0 and 1, indicating the location along the curve to plot an arrow. If - a 2D array is passed, the first row will be used to specify arrow - locations for the primary curve and the second row will be used for - the mirror image. - - arrow_size : float, optional - Arrowhead width and length (in display coordinates). Default value is - 8 and can be set using config.defaults['nyquist.arrow_size']. - - arrow_style : matplotlib.patches.ArrowStyle, optional - Define style used for Nyquist curve arrows (overrides `arrow_size`). - encirclement_threshold : float, optional Define the threshold for generating a warning if the number of net encirclements is a non-integer value. Default value is 0.05 and can @@ -1221,43 +1228,6 @@ def nyquist_plot( imaginary axis. Portions of the Nyquist plot corresponding to indented portions of the contour are plotted using a different line style. - label_freq : int, optiona - Label every nth frequency on the plot. If not specified, no labels - are generated. - - max_curve_magnitude : float, optional - Restrict the maximum magnitude of the Nyquist plot to this value. - Portions of the Nyquist plot whose magnitude is restricted are - plotted using a different line style. - - max_curve_offset : float, optional - When plotting scaled portion of the Nyquist plot, increase/decrease - the magnitude by this fraction of the max_curve_magnitude to allow - any overlaps between the primary and mirror curves to be avoided. - - mirror_style : [str, str] or False - Linestyles for mirror image of the Nyquist curve. The first element - is used for unscaled portions of the Nyquist curve, the second element - is used for portions that are scaled (using max_curve_magnitude). If - `False` then omit completely. Default linestyle (['--', ':']) is - determined by config.defaults['nyquist.mirror_style']. - - primary_style : [str, str], optional - Linestyles for primary image of the Nyquist curve. The first - element is used for unscaled portions of the Nyquist curve, - the second element is used for portions that are scaled (using - max_curve_magnitude). Default linestyle (['-', '-.']) is - determined by config.defaults['nyquist.mirror_style']. - - start_marker : str, optional - Matplotlib marker to use to mark the starting point of the Nyquist - plot. Defaults value is 'o' and can be set using - config.defaults['nyquist.start_marker']. - - start_marker_size : float, optional - Start marker size (in display coordinates). Default value is - 4 and can be set using config.defaults['nyquist.start_marker_size']. - warn_nyquist : bool, optional If set to 'False', turn off warnings about frequencies above Nyquist. @@ -1292,18 +1262,14 @@ def nyquist_plot( Examples -------- >>> G = ct.zpk([], [-1, -2, -3], gain=100) - >>> ct.nyquist_plot(G) - 2 + >>> response = ct.nyquist_response(G) + >>> count = response.count + >>> response.plot() """ # Get values for params (and pop from list to allow keyword use in plot) omega_num_given = omega_num is not None omega_num = config._get_param('freqplot', 'number_of_samples', omega_num) - arrows = config._get_param( - 'nyquist', 'arrows', kwargs, _nyquist_defaults, pop=True) - arrow_size = config._get_param( - 'nyquist', 'arrow_size', kwargs, _nyquist_defaults, pop=True) - arrow_style = config._get_param('nyquist', 'arrow_style', kwargs, None) indent_radius = config._get_param( 'nyquist', 'indent_radius', kwargs, _nyquist_defaults, pop=True) encirclement_threshold = config._get_param( @@ -1313,33 +1279,9 @@ def nyquist_plot( 'nyquist', 'indent_direction', kwargs, _nyquist_defaults, pop=True) indent_points = config._get_param( 'nyquist', 'indent_points', kwargs, _nyquist_defaults, pop=True) - max_curve_magnitude = config._get_param( - 'nyquist', 'max_curve_magnitude', kwargs, _nyquist_defaults, pop=True) - max_curve_offset = config._get_param( - 'nyquist', 'max_curve_offset', kwargs, _nyquist_defaults, pop=True) - start_marker = config._get_param( - 'nyquist', 'start_marker', kwargs, _nyquist_defaults, pop=True) - start_marker_size = config._get_param( - 'nyquist', 'start_marker_size', kwargs, _nyquist_defaults, pop=True) - # Set line styles for the curves - def _parse_linestyle(style_name, allow_false=False): - style = config._get_param( - 'nyquist', style_name, kwargs, _nyquist_defaults, pop=True) - if isinstance(style, str): - # Only one style provided, use the default for the other - style = [style, _nyquist_defaults['nyquist.' + style_name][1]] - warnings.warn( - "use of a single string for linestyle will be deprecated " - " in a future release", PendingDeprecationWarning) - if (allow_false and style is False) or \ - (isinstance(style, list) and len(style) == 2): - return style - else: - raise ValueError(f"invalid '{style_name}': {style}") - - primary_style = _parse_linestyle('primary_style') - mirror_style = _parse_linestyle('mirror_style', allow_false=True) + if check_kwargs and kwargs: + raise TypeError("unrecognized keywords: ", str(kwargs)) # If argument was a singleton, turn it into a tuple if not isinstance(syslist, (list, tuple)): @@ -1360,8 +1302,8 @@ def _parse_linestyle(style_name, allow_false=False): np.linspace(0, omega[0], indent_points), omega[1:])) # Go through each system and keep track of the results - counts, contours = [], [] - for sys in syslist: + responses = [] + for idx, sys in enumerate(syslist): if not sys.issiso(): # TODO: Add MIMO nyquist plots. raise ControlMIMONotImplemented( @@ -1375,7 +1317,7 @@ def _parse_linestyle(style_name, allow_false=False): # Restrict frequencies for discrete-time systems nyquistfrq = math.pi / sys.dt if not omega_range_given: - # limit up to and including nyquist frequency + # limit up to and including Nyquist frequency omega_sys = np.hstack(( omega_sys[omega_sys < nyquistfrq], nyquistfrq)) @@ -1474,7 +1416,8 @@ def _parse_linestyle(style_name, allow_false=False): # See if we need to indent around it if abs(s - p) < indent_radius: # Figure out how much to offset (simple trigonometry) - offset = np.sqrt(indent_radius ** 2 - (s - p).imag ** 2) \ + offset = np.sqrt( + indent_radius ** 2 - (s - p).imag ** 2) \ - (s - p).real # Figure out which way to offset the contour point @@ -1489,7 +1432,8 @@ def _parse_linestyle(style_name, allow_false=False): splane_contour[i] -= offset else: - raise ValueError("unknown value for indent_direction") + raise ValueError( + "unknown value for indent_direction") # change contour to z-plane if necessary if sys.isctime(): @@ -1548,140 +1492,441 @@ def _parse_linestyle(style_name, allow_false=False): " turned off; results may be meaningless", RuntimeWarning, stacklevel=2) - counts.append(count) - contours.append(contour) - - if plot: - # Parse the arrows keyword - if not arrows: - arrow_pos = [] - elif isinstance(arrows, int): - N = arrows - # Space arrows out, starting midway along each "region" - arrow_pos = np.linspace(0.5/N, 1 + 0.5/N, N, endpoint=False) - elif isinstance(arrows, (list, np.ndarray)): - arrow_pos = np.sort(np.atleast_1d(arrows)) - else: - raise ValueError("unknown or unsupported arrow location") - - # Set the arrow style - if arrow_style is None: - arrow_style = mpl.patches.ArrowStyle( - 'simple', head_width=arrow_size, head_length=arrow_size) - - # Find the different portions of the curve (with scaled pts marked) - reg_mask = np.logical_or( - np.abs(resp) > max_curve_magnitude, - splane_contour.real != 0) - # reg_mask = np.logical_or( - # np.abs(resp.real) > max_curve_magnitude, - # np.abs(resp.imag) > max_curve_magnitude) - - scale_mask = ~reg_mask \ - & np.concatenate((~reg_mask[1:], ~reg_mask[-1:])) \ - & np.concatenate((~reg_mask[0:1], ~reg_mask[:-1])) - - # Rescale the points with large magnitude - rescale = np.logical_and( - reg_mask, abs(resp) > max_curve_magnitude) - resp[rescale] *= max_curve_magnitude / abs(resp[rescale]) - - # Plot the regular portions of the curve (and grab the color) - x_reg = np.ma.masked_where(reg_mask, resp.real) - y_reg = np.ma.masked_where(reg_mask, resp.imag) - p = plt.plot( - x_reg, y_reg, primary_style[0], color=color, **kwargs) - c = p[0].get_color() - - # Figure out how much to offset the curve: the offset goes from - # zero at the start of the scaled section to max_curve_offset as - # we move along the curve - curve_offset = _compute_curve_offset( - resp, scale_mask, max_curve_offset) - - # Plot the scaled sections of the curve (changing linestyle) - x_scl = np.ma.masked_where(scale_mask, resp.real) - y_scl = np.ma.masked_where(scale_mask, resp.imag) + # Decide on system name + sysname = sys.name if sys.name is not None else f"Unknown-{idx}" + + responses.append(NyquistResponseData( + count, contour, resp, sys.dt, sysname=sysname, + return_contour=return_contour)) + + # Return response + if len(responses) == 1: # TODO: update to match input type + return responses[0] + else: + return NyquistResponseList(responses) + + +def nyquist_plot( + data, omega=None, plot=None, omega_limits=None, omega_num=None, + label_freq=0, color=None, return_contour=None, title=None, + legend_loc='upper right', **kwargs): + """Nyquist plot for a system. + + Generates a Nyquist plot for the system over a (optional) frequency + range. The curve is computed by evaluating the Nyqist segment along + the positive imaginary axis, with a mirror image generated to reflect + the negative imaginary axis. Poles on or near the imaginary axis are + avoided using a small indentation. The portion of the Nyquist contour + at infinity is not explicitly computed (since it maps to a constant + value for any system with a proper transfer function). + + Parameters + ---------- + data : list of LTI or NyquistResponseData + List of linear input/output systems (single system is OK) or + Nyquist ersponses (computed using :func:`~control.nyquist_response`). + Nyquist curves for each system are plotted on the same graph. + + omega : array_like, optional + Set of frequencies to be evaluated, in rad/sec. + + omega_limits : array_like of two values, optional + Limits to the range of frequencies. Ignored if omega is provided, and + auto-generated if omitted. + + omega_num : int, optional + Number of frequency samples to plot. Defaults to + config.defaults['freqplot.number_of_samples']. + + color : string, optional + Used to specify the color of the line and arrowhead. + + return_contour : bool, optional + If 'True', return the contour used to evaluate the Nyquist plot. + + **kwargs : :func:`matplotlib.pyplot.plot` keyword properties, optional + Additional keywords (passed to `matplotlib`) + + Returns + ------- + out : array of Line2D + 2D array of Line2D objects for each line in the plot. The shape of + the array is given by (nsys, 4) where nsys is the number of systems + or Nyquist responses passed to the function. The second index + specifies the segment type: + + 0: unscaled portion of the primary curve + 1: scaled portion of the primary curve + 2: unscaled portion of the mirror curve + 3: scaled portion of the mirror curve + + Other Parameters + ---------------- + arrows : int or 1D/2D array of floats, optional + Specify the number of arrows to plot on the Nyquist curve. If an + integer is passed. that number of equally spaced arrows will be + plotted on each of the primary segment and the mirror image. If a 1D + array is passed, it should consist of a sorted list of floats between + 0 and 1, indicating the location along the curve to plot an arrow. If + a 2D array is passed, the first row will be used to specify arrow + locations for the primary curve and the second row will be used for + the mirror image. + + arrow_size : float, optional + Arrowhead width and length (in display coordinates). Default value is + 8 and can be set using config.defaults['nyquist.arrow_size']. + + arrow_style : matplotlib.patches.ArrowStyle, optional + Define style used for Nyquist curve arrows (overrides `arrow_size`). + + encirclement_threshold : float, optional + Define the threshold for generating a warning if the number of net + encirclements is a non-integer value. Default value is 0.05 and can + be set using config.defaults['nyquist.encirclement_threshold']. + + indent_direction : str, optional + For poles on the imaginary axis, set the direction of indentation to + be 'right' (default), 'left', or 'none'. + + indent_points : int, optional + Number of points to insert in the Nyquist contour around poles that + are at or near the imaginary axis. + + indent_radius : float, optional + Amount to indent the Nyquist contour around poles on or near the + imaginary axis. Portions of the Nyquist plot corresponding to indented + portions of the contour are plotted using a different line style. + + label_freq : int, optiona + Label every nth frequency on the plot. If not specified, no labels + are generated. + + max_curve_magnitude : float, optional + Restrict the maximum magnitude of the Nyquist plot to this value. + Portions of the Nyquist plot whose magnitude is restricted are + plotted using a different line style. + + max_curve_offset : float, optional + When plotting scaled portion of the Nyquist plot, increase/decrease + the magnitude by this fraction of the max_curve_magnitude to allow + any overlaps between the primary and mirror curves to be avoided. + + mirror_style : [str, str] or False + Linestyles for mirror image of the Nyquist curve. The first element + is used for unscaled portions of the Nyquist curve, the second element + is used for portions that are scaled (using max_curve_magnitude). If + `False` then omit completely. Default linestyle (['--', ':']) is + determined by config.defaults['nyquist.mirror_style']. + + plot : bool, optional + (legacy) If given, `bode_plot` returns the legacy return values + of magnitude, phase, and frequency. If False, just return the + values with no plot. + + primary_style : [str, str], optional + Linestyles for primary image of the Nyquist curve. The first + element is used for unscaled portions of the Nyquist curve, + the second element is used for portions that are scaled (using + max_curve_magnitude). Default linestyle (['-', '-.']) is + determined by config.defaults['nyquist.mirror_style']. + + start_marker : str, optional + Matplotlib marker to use to mark the starting point of the Nyquist + plot. Defaults value is 'o' and can be set using + config.defaults['nyquist.start_marker']. + + start_marker_size : float, optional + Start marker size (in display coordinates). Default value is + 4 and can be set using config.defaults['nyquist.start_marker_size']. + + warn_nyquist : bool, optional + If set to 'False', turn off warnings about frequencies above Nyquist. + + warn_encirclements : bool, optional + If set to 'False', turn off warnings about number of encirclements not + meeting the Nyquist criterion. + + Notes + ----- + 1. If a discrete time model is given, the frequency response is computed + along the upper branch of the unit circle, using the mapping ``z = + exp(1j * omega * dt)`` where `omega` ranges from 0 to `pi/dt` and `dt` + is the discrete timebase. If timebase not specified (``dt=True``), + `dt` is set to 1. + + 2. If a continuous-time system contains poles on or near the imaginary + axis, a small indentation will be used to avoid the pole. The radius + of the indentation is given by `indent_radius` and it is taken to the + right of stable poles and the left of unstable poles. If a pole is + exactly on the imaginary axis, the `indent_direction` parameter can be + used to set the direction of indentation. Setting `indent_direction` + to `none` will turn off indentation. If `return_contour` is True, the + exact contour used for evaluation is returned. + + 3. For those portions of the Nyquist plot in which the contour is + indented to avoid poles, resuling in a scaling of the Nyquist plot, + the line styles are according to the settings of the `primary_style` + and `mirror_style` keywords. By default the scaled portions of the + primary curve use a dotted line style and the scaled portion of the + mirror image use a dashdot line style. + + Examples + -------- + >>> G = ct.zpk([], [-1, -2, -3], gain=100) + >>> out = ct.nyquist_plot(G) + + """ + # Get values for params (and pop from list to allow keyword use in plot) + omega_num_given = omega_num is not None + omega_num = config._get_param('freqplot', 'number_of_samples', omega_num) + arrows = config._get_param( + 'nyquist', 'arrows', kwargs, _nyquist_defaults, pop=True) + arrow_size = config._get_param( + 'nyquist', 'arrow_size', kwargs, _nyquist_defaults, pop=True) + arrow_style = config._get_param('nyquist', 'arrow_style', kwargs, None) + max_curve_magnitude = config._get_param( + 'nyquist', 'max_curve_magnitude', kwargs, _nyquist_defaults, pop=True) + max_curve_offset = config._get_param( + 'nyquist', 'max_curve_offset', kwargs, _nyquist_defaults, pop=True) + start_marker = config._get_param( + 'nyquist', 'start_marker', kwargs, _nyquist_defaults, pop=True) + start_marker_size = config._get_param( + 'nyquist', 'start_marker_size', kwargs, _nyquist_defaults, pop=True) + + # Set line styles for the curves + def _parse_linestyle(style_name, allow_false=False): + style = config._get_param( + 'nyquist', style_name, kwargs, _nyquist_defaults, pop=True) + if isinstance(style, str): + # Only one style provided, use the default for the other + style = [style, _nyquist_defaults['nyquist.' + style_name][1]] + warnings.warn( + "use of a single string for linestyle will be deprecated " + " in a future release", PendingDeprecationWarning) + if (allow_false and style is False) or \ + (isinstance(style, list) and len(style) == 2): + return style + else: + raise ValueError(f"invalid '{style_name}': {style}") + + primary_style = _parse_linestyle('primary_style') + mirror_style = _parse_linestyle('mirror_style', allow_false=True) + + # Parse the arrows keyword + if not arrows: + arrow_pos = [] + elif isinstance(arrows, int): + N = arrows + # Space arrows out, starting midway along each "region" + arrow_pos = np.linspace(0.5/N, 1 + 0.5/N, N, endpoint=False) + elif isinstance(arrows, (list, np.ndarray)): + arrow_pos = np.sort(np.atleast_1d(arrows)) + else: + raise ValueError("unknown or unsupported arrow location") + + # If argument was a singleton, turn it into a tuple + if not isinstance(data, (list, tuple)): + data = (data,) + + # If we are passed a list of systems, compute response first + # If we were passed a list of systems, convert to data + if all([isinstance( + sys, (StateSpace, TransferFunction, FrequencyResponseData)) + for sys in data]): + nyquist_responses = nyquist_response( + data, omega=omega, omega_limits=omega_limits, omega_num=omega_num, + check_kwargs=False, **kwargs) + if not isinstance(nyquist_responses, list): + nyquist_responses = [nyquist_responses] + else: + nyquist_responses = data + + # Legacy return value processing + if plot is not None or return_contour is not None: + warnings.warn( + "`nyquist_plot` return values of count[, contour] is deprecated; " + "use nyquist_response()", DeprecationWarning) + + # Extract out the values that we will eventually return + counts = [response.count for response in nyquist_responses] + contours = [response.contour for response in nyquist_responses] + + if plot is False: + if len(data) == 1: + counts, contours = counts[0], contours[0] + + # Return counts and (optionally) the contour we used + return (counts, contours) if return_contour else counts + + # Create a list of lines for the output + out = np.empty(len(nyquist_responses), dtype=object) + for i in range(out.shape[0]): + out[i] = [] # unique list in each element + + # Set the arrow style + if arrow_style is None: + arrow_style = mpl.patches.ArrowStyle( + 'simple', head_width=arrow_size, head_length=arrow_size) + + for idx, response in enumerate(nyquist_responses): + resp = response.response + if response.dt in [0, None]: + splane_contour = response.contour + else: + splane_contour = np.log(response.contour) / response.dt + + # Find the different portions of the curve (with scaled pts marked) + reg_mask = np.logical_or( + np.abs(resp) > max_curve_magnitude, + splane_contour.real != 0) + # reg_mask = np.logical_or( + # np.abs(resp.real) > max_curve_magnitude, + # np.abs(resp.imag) > max_curve_magnitude) + + scale_mask = ~reg_mask \ + & np.concatenate((~reg_mask[1:], ~reg_mask[-1:])) \ + & np.concatenate((~reg_mask[0:1], ~reg_mask[:-1])) + + # Rescale the points with large magnitude + rescale = np.logical_and( + reg_mask, abs(resp) > max_curve_magnitude) + resp[rescale] *= max_curve_magnitude / abs(resp[rescale]) + + # Plot the regular portions of the curve (and grab the color) + x_reg = np.ma.masked_where(reg_mask, resp.real) + y_reg = np.ma.masked_where(reg_mask, resp.imag) + p = plt.plot( + x_reg, y_reg, primary_style[0], color=color, + label=response.sysname, **kwargs) + c = p[0].get_color() + out[idx] += p + + # Figure out how much to offset the curve: the offset goes from + # zero at the start of the scaled section to max_curve_offset as + # we move along the curve + curve_offset = _compute_curve_offset( + resp, scale_mask, max_curve_offset) + + # Plot the scaled sections of the curve (changing linestyle) + x_scl = np.ma.masked_where(scale_mask, resp.real) + y_scl = np.ma.masked_where(scale_mask, resp.imag) + if x_scl.count() >= 1 and y_scl.count() >= 1: + out[idx] += plt.plot( + x_scl * (1 + curve_offset), + y_scl * (1 + curve_offset), + primary_style[1], color=c, **kwargs) + else: + out[idx] += [None] + + # Plot the primary curve (invisible) for setting arrows + x, y = resp.real.copy(), resp.imag.copy() + x[reg_mask] *= (1 + curve_offset[reg_mask]) + y[reg_mask] *= (1 + curve_offset[reg_mask]) + p = plt.plot(x, y, linestyle='None', color=c, **kwargs) + + # Add arrows + ax = plt.gca() + _add_arrows_to_line2D( + ax, p[0], arrow_pos, arrowstyle=arrow_style, dir=1) + + # Plot the mirror image + if mirror_style is not False: + # Plot the regular and scaled segments + out[idx] += plt.plot( + x_reg, -y_reg, mirror_style[0], color=c, **kwargs) if x_scl.count() >= 1 and y_scl.count() >= 1: - plt.plot( - x_scl * (1 + curve_offset), - y_scl * (1 + curve_offset), - primary_style[1], color=c, **kwargs) + out[idx] += plt.plot( + x_scl * (1 - curve_offset), + -y_scl * (1 - curve_offset), + mirror_style[1], color=c, **kwargs) + else: + out[idx] += [None] - # Plot the primary curve (invisible) for setting arrows + # Add the arrows (on top of an invisible contour) x, y = resp.real.copy(), resp.imag.copy() - x[reg_mask] *= (1 + curve_offset[reg_mask]) - y[reg_mask] *= (1 + curve_offset[reg_mask]) - p = plt.plot(x, y, linestyle='None', color=c, **kwargs) - - # Add arrows - ax = plt.gca() + x[reg_mask] *= (1 - curve_offset[reg_mask]) + y[reg_mask] *= (1 - curve_offset[reg_mask]) + p = plt.plot(x, -y, linestyle='None', color=c, **kwargs) _add_arrows_to_line2D( - ax, p[0], arrow_pos, arrowstyle=arrow_style, dir=1) - - # Plot the mirror image - if mirror_style is not False: - # Plot the regular and scaled segments - plt.plot( - x_reg, -y_reg, mirror_style[0], color=c, **kwargs) - if x_scl.count() >= 1 and y_scl.count() >= 1: - plt.plot( - x_scl * (1 - curve_offset), - -y_scl * (1 - curve_offset), - mirror_style[1], color=c, **kwargs) - - # Add the arrows (on top of an invisible contour) - x, y = resp.real.copy(), resp.imag.copy() - x[reg_mask] *= (1 - curve_offset[reg_mask]) - y[reg_mask] *= (1 - curve_offset[reg_mask]) - p = plt.plot(x, -y, linestyle='None', color=c, **kwargs) - _add_arrows_to_line2D( - ax, p[0], arrow_pos, arrowstyle=arrow_style, dir=-1) - - # Mark the start of the curve - if start_marker: - plt.plot(resp[0].real, resp[0].imag, start_marker, - color=c, markersize=start_marker_size) - - # 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_sys[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() - ax.set_xlabel("Real axis") - ax.set_ylabel("Imaginary axis") - ax.grid(color="lightgray") + ax, p[0], arrow_pos, arrowstyle=arrow_style, dir=-1) + else: + out[idx] += [None, None] + + # Mark the start of the curve + if start_marker: + plt.plot(resp[0].real, resp[0].imag, start_marker, + color=c, markersize=start_marker_size) + + # Mark the -1 point + plt.plot([-1], [0], 'r+') + + # Label the frequencies of the points + if label_freq: + ind = slice(None, None, label_freq) + omega_sys = np.imag(splane_contour[np.real(splane_contour) == 0]) + for xpt, ypt, omegapt in zip(x[ind], y[ind], omega_sys[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') + + # Label the axes + fig, ax = plt.gcf(), plt.gca() + ax.set_xlabel("Real axis") + ax.set_ylabel("Imaginary axis") + ax.grid(color="lightgray") - # "Squeeze" the results - if len(syslist) == 1: - counts, contours = counts[0], contours[0] + # List of systems that are included in this plot + labels, lines = [], [] + last_color, counter = None, 0 # label unknown systems + for i, line in enumerate(ax.get_lines()): + label = line.get_label() + if label.startswith("Unknown"): + label = f"Unknown-{counter}" + if last_color is None: + last_color = line.get_color() + elif last_color != line.get_color(): + counter += 1 + last_color = line.get_color() + elif label[0] == '_': + continue - # Return counts and (optionally) the contour we used - return (counts, contours) if return_contour else counts + if label not in labels: + lines.append(line) + labels.append(label) + + # Add legend if there is more than one system plotted + if len(labels) > 1: + ax.legend(lines, labels, loc=legend_loc) + + # Add the title + if title is None: + title = "Nyquist plot for " + ", ".join(labels) + fig.suptitle(title) + + if plot is True or return_contour is not None: + if len(data) == 1: + counts, contours = counts[0], contours[0] + + # Return counts and (optionally) the contour we used + return (counts, contours) if return_contour else counts + + return out # Internal function to add arrows to a curve @@ -1757,6 +2002,7 @@ def _add_arrows_to_line2D( return arrows + # # Function to compute Nyquist curve offsets # diff --git a/control/lti.py b/control/lti.py index 14594f00f..d7355395d 100644 --- a/control/lti.py +++ b/control/lti.py @@ -13,7 +13,7 @@ from .iosys import InputOutputSystem __all__ = ['poles', 'zeros', 'damp', 'evalfr', 'frequency_response', - 'freqresp', 'dcgain', 'bandwidth'] + 'freqresp', 'dcgain', 'bandwidth', 'LTI'] class LTI(InputOutputSystem): @@ -466,10 +466,8 @@ def frequency_response( if sys_.isdtime(strict=True): nyquistfrq = math.pi / sys_.dt if not omega_range_given: - # limit up to and including nyquist frequency - # TODO: make this optional? - omega_sys = np.hstack(( - omega_sys[omega_sys < nyquistfrq], nyquistfrq)) + # Limit up to the Nyquist frequency + omega_sys = omega_sys[omega_sys < nyquistfrq] # Compute the frequency response responses.append(sys_.frequency_response(omega_sys, squeeze=squeeze)) diff --git a/control/matlab/wrappers.py b/control/matlab/wrappers.py index 5eb7786fe..04102d497 100644 --- a/control/matlab/wrappers.py +++ b/control/matlab/wrappers.py @@ -90,7 +90,7 @@ def bode(*args, **kwargs): return retval -def nyquist(*args, **kwargs): +def nyquist(*args, plot=True, **kwargs): """nyquist(syslist[, omega]) Nyquist plot of the frequency response. @@ -114,7 +114,7 @@ def nyquist(*args, **kwargs): frequencies in rad/s """ - from ..freqplot import nyquist_plot + from ..freqplot import nyquist_response, nyquist_plot # If first argument is a list, assume python-control calling format if hasattr(args[0], '__iter__'): @@ -125,8 +125,10 @@ def nyquist(*args, **kwargs): kwargs.update(other) # Call the nyquist command - kwargs['return_contour'] = True - _, contour = nyquist_plot(syslist, omega, *args, **kwargs) + response = nyquist_response(syslist, omega, *args, **kwargs) + contour = response.contour + if plot: + nyquist_plot(response, *args, **kwargs) # Create the MATLAB output arguments freqresp = syslist(contour) diff --git a/control/tests/kwargs_test.py b/control/tests/kwargs_test.py index ec20a5a1a..5e5cc71be 100644 --- a/control/tests/kwargs_test.py +++ b/control/tests/kwargs_test.py @@ -110,6 +110,8 @@ def test_kwarg_search(module, prefix): (lambda x, u, params: None, lambda zflag, params: None), {}), (control.InputOutputSystem, 0, 0, (), {'inputs': 1, 'outputs': 1, 'states': 1}), + (control.LTI, 0, 0, (), + {'inputs': 1, 'outputs': 1, 'states': 1}), (control.flatsys.LinearFlatSystem, 1, 0, (), {}), (control.NonlinearIOSystem.linearize, 1, 0, (0, 0), {}), (control.StateSpace.sample, 1, 0, (0.1,), {}), @@ -156,26 +158,32 @@ def test_matplotlib_kwargs(function, nsysargs, moreargs, kwargs, mplcleanup): function(*args, **kwargs) # Now add an unrecognized keyword and make sure there is an error - with pytest.raises(AttributeError, - match="(has no property|unexpected keyword)"): + with pytest.raises( + (AttributeError, TypeError), + match="(has no property|unexpected keyword|unrecognized keyword)"): function(*args, **kwargs, unknown=None) @pytest.mark.parametrize( - "data_fcn, plot_fcn", [ - (control.step_response, control.time_response_plot), - (control.step_response, control.TimeResponseData.plot), - (control.frequency_response, control.FrequencyResponseData.plot), - (control.frequency_response, control.bode), - (control.frequency_response, control.bode_plot), + "data_fcn, plot_fcn, mimo", [ + (control.step_response, control.time_response_plot, True), + (control.step_response, control.TimeResponseData.plot, True), + (control.frequency_response, control.FrequencyResponseData.plot, True), + (control.frequency_response, control.bode, True), + (control.frequency_response, control.bode_plot, True), + (control.nyquist_response, control.nyquist_plot, False), ]) -def test_response_plot_kwargs(data_fcn, plot_fcn): +def test_response_plot_kwargs(data_fcn, plot_fcn, mimo): # Create a system for testing - response = data_fcn(control.rss(4, 2, 2)) + if mimo: + response = data_fcn(control.rss(4, 2, 2)) + else: + response = data_fcn(control.rss(4, 1, 1)) # Make sure that calling the data function with unknown keyword errs - with pytest.raises((AttributeError, TypeError), - match="(has no property|unexpected keyword)"): + with pytest.raises( + (AttributeError, TypeError), + match="(has no property|unexpected keyword|unrecognized keyword)"): data_fcn(control.rss(2, 1, 1), unknown=None) # Call the plotting function normally and make sure it works @@ -216,6 +224,7 @@ def test_response_plot_kwargs(data_fcn, plot_fcn): 'lqr': test_unrecognized_kwargs, 'nlsys': test_unrecognized_kwargs, 'nyquist': test_matplotlib_kwargs, + 'nyquist_response': test_response_plot_kwargs, 'nyquist_plot': test_matplotlib_kwargs, 'pzmap': test_unrecognized_kwargs, 'rlocus': test_unrecognized_kwargs, @@ -245,6 +254,7 @@ def test_response_plot_kwargs(data_fcn, plot_fcn): frd_test.TestFRD.test_unrecognized_keyword, 'FrequencyResponseData.plot': test_response_plot_kwargs, 'InputOutputSystem.__init__': test_unrecognized_kwargs, + 'LTI.__init__': test_unrecognized_kwargs, 'flatsys.LinearFlatSystem.__init__': test_unrecognized_kwargs, 'NonlinearIOSystem.linearize': test_unrecognized_kwargs, 'InterconnectedSystem.__init__': diff --git a/control/tests/nyquist_test.py b/control/tests/nyquist_test.py index 773e7e943..ad630b71b 100644 --- a/control/tests/nyquist_test.py +++ b/control/tests/nyquist_test.py @@ -40,7 +40,7 @@ def _Z(sys): def test_nyquist_basic(): # Simple Nyquist plot sys = ct.rss(5, 1, 1) - N_sys = ct.nyquist_plot(sys) + N_sys = ct.nyquist_response(sys) assert _Z(sys) == N_sys + _P(sys) # Previously identified bug @@ -62,17 +62,17 @@ def test_nyquist_basic(): sys = ct.ss(A, B, C, D) # With a small indent_radius, all should be fine - N_sys = ct.nyquist_plot(sys, indent_radius=0.001) + N_sys = ct.nyquist_response(sys, indent_radius=0.001) assert _Z(sys) == N_sys + _P(sys) # With a larger indent_radius, we get a warning message + wrong answer with pytest.warns(UserWarning, match="contour may miss closed loop pole"): - N_sys = ct.nyquist_plot(sys, indent_radius=0.2) + N_sys = ct.nyquist_response(sys, indent_radius=0.2) assert _Z(sys) != N_sys + _P(sys) # Unstable system sys = ct.tf([10], [1, 2, 2, 1]) - N_sys = ct.nyquist_plot(sys) + N_sys = ct.nyquist_response(sys) assert _Z(sys) > 0 assert _Z(sys) == N_sys + _P(sys) @@ -80,14 +80,14 @@ def test_nyquist_basic(): sys1 = ct.rss(3, 1, 1) sys2 = ct.rss(4, 1, 1) sys3 = ct.rss(5, 1, 1) - counts = ct.nyquist_plot([sys1, sys2, sys3]) + counts = ct.nyquist_response([sys1, sys2, sys3]) for N_sys, sys in zip(counts, [sys1, sys2, sys3]): assert _Z(sys) == N_sys + _P(sys) # Nyquist plot with poles at the origin, omega specified sys = ct.tf([1], [1, 3, 2]) * ct.tf([1], [1, 0]) omega = np.linspace(0, 1e2, 100) - count, contour = ct.nyquist_plot(sys, omega, return_contour=True) + count, contour = ct.nyquist_response(sys, omega, return_contour=True) np.testing.assert_array_equal( contour[contour.real < 0], omega[contour.real < 0]) @@ -100,50 +100,50 @@ def test_nyquist_basic(): # Make sure that we can turn off frequency modification # # Start with a case where indentation should occur - count, contour_indented = ct.nyquist_plot( + count, contour_indented = ct.nyquist_response( sys, np.linspace(1e-4, 1e2, 100), indent_radius=1e-2, return_contour=True) assert not all(contour_indented.real == 0) with pytest.warns(UserWarning, match="encirclements does not match"): - count, contour = ct.nyquist_plot( + count, contour = ct.nyquist_response( sys, np.linspace(1e-4, 1e2, 100), indent_radius=1e-2, return_contour=True, indent_direction='none') np.testing.assert_almost_equal(contour, 1j*np.linspace(1e-4, 1e2, 100)) # Nyquist plot with poles at the origin, omega unspecified sys = ct.tf([1], [1, 3, 2]) * ct.tf([1], [1, 0]) - count, contour = ct.nyquist_plot(sys, return_contour=True) + count, contour = ct.nyquist_response(sys, return_contour=True) assert _Z(sys) == count + _P(sys) # Nyquist plot with poles at the origin, return contour sys = ct.tf([1], [1, 3, 2]) * ct.tf([1], [1, 0]) - count, contour = ct.nyquist_plot(sys, return_contour=True) + count, contour = ct.nyquist_response(sys, return_contour=True) assert _Z(sys) == count + _P(sys) # Nyquist plot with poles on imaginary axis, omega specified # (can miss encirclements due to the imaginary poles at +/- 1j) sys = ct.tf([1], [1, 3, 2]) * ct.tf([1], [1, 0, 1]) with pytest.warns(UserWarning, match="does not match") as records: - count = ct.nyquist_plot(sys, np.linspace(1e-3, 1e1, 1000)) + count = ct.nyquist_response(sys, np.linspace(1e-3, 1e1, 1000)) if len(records) == 0: assert _Z(sys) == count + _P(sys) # Nyquist plot with poles on imaginary axis, omega specified, with contour sys = ct.tf([1], [1, 3, 2]) * ct.tf([1], [1, 0, 1]) with pytest.warns(UserWarning, match="does not match") as records: - count, contour = ct.nyquist_plot( + count, contour = ct.nyquist_response( sys, np.linspace(1e-3, 1e1, 1000), return_contour=True) if len(records) == 0: assert _Z(sys) == count + _P(sys) # Nyquist plot with poles on imaginary axis, return contour sys = ct.tf([1], [1, 3, 2]) * ct.tf([1], [1, 0, 1]) - count, contour = ct.nyquist_plot(sys, return_contour=True) + count, contour = ct.nyquist_response(sys, return_contour=True) assert _Z(sys) == count + _P(sys) # Nyquist plot with poles at the origin and on imaginary axis sys = ct.tf([1], [1, 3, 2]) * ct.tf([1], [1, 0, 1]) * ct.tf([1], [1, 0]) - count, contour = ct.nyquist_plot(sys, return_contour=True) + count, contour = ct.nyquist_response(sys, return_contour=True) assert _Z(sys) == count + _P(sys) @@ -155,34 +155,39 @@ def test_nyquist_fbs_examples(): plt.figure() plt.title("Figure 10.4: L(s) = 1.4 e^{-s}/(s+1)^2") sys = ct.tf([1.4], [1, 2, 1]) * ct.tf(*ct.pade(1, 4)) - count = ct.nyquist_plot(sys) - assert _Z(sys) == count + _P(sys) + response = ct.nyquist_response(sys) + response.plot() + assert _Z(sys) == response.count + _P(sys) plt.figure() plt.title("Figure 10.4: L(s) = 1/(s + a)^2 with a = 0.6") sys = 1/(s + 0.6)**3 - count = ct.nyquist_plot(sys) - assert _Z(sys) == count + _P(sys) + response = ct.nyquist_response(sys) + response.plot() + assert _Z(sys) == response.count + _P(sys) plt.figure() plt.title("Figure 10.6: L(s) = 1/(s (s+1)^2) - pole at the origin") sys = 1/(s * (s+1)**2) - count = ct.nyquist_plot(sys) - assert _Z(sys) == count + _P(sys) + response = ct.nyquist_response(sys) + response.plot() + assert _Z(sys) == response.count + _P(sys) plt.figure() plt.title("Figure 10.10: L(s) = 3 (s+6)^2 / (s (s+1)^2)") sys = 3 * (s+6)**2 / (s * (s+1)**2) - count = ct.nyquist_plot(sys) - assert _Z(sys) == count + _P(sys) + response = ct.nyquist_response(sys) + response.plot() + assert _Z(sys) == response.count + _P(sys) plt.figure() plt.title("Figure 10.10: L(s) = 3 (s+6)^2 / (s (s+1)^2) [zoom]") with pytest.warns(UserWarning, match="encirclements does not match"): - count = ct.nyquist_plot(sys, omega_limits=[1.5, 1e3]) + response = ct.nyquist_response(sys, omega_limits=[1.5, 1e3]) + response.plot() # Frequency limits for zoom give incorrect encirclement count - # assert _Z(sys) == count + _P(sys) - assert count == -1 + # assert _Z(sys) == response.count + _P(sys) + assert response.count == -1 @pytest.mark.parametrize("arrows", [ @@ -195,8 +200,9 @@ def test_nyquist_arrows(arrows): sys = ct.tf([1.4], [1, 2, 1]) * ct.tf(*ct.pade(1, 4)) plt.figure(); plt.title("L(s) = 1.4 e^{-s}/(s+1)^2 / arrows = %s" % arrows) - count = ct.nyquist_plot(sys, arrows=arrows) - assert _Z(sys) == count + _P(sys) + response = ct.nyquist_response(sys) + response.plot(arrows=arrows) + assert _Z(sys) == response.count + _P(sys) def test_nyquist_encirclements(): @@ -205,34 +211,38 @@ def test_nyquist_encirclements(): sys = (0.02 * s**3 - 0.1 * s) / (s**4 + s**3 + s**2 + 0.25 * s + 0.04) plt.figure(); - count = ct.nyquist_plot(sys) - plt.title("Stable system; encirclements = %d" % count) - assert _Z(sys) == count + _P(sys) + response = ct.nyquist_response(sys) + response.plot() + plt.title("Stable system; encirclements = %d" % response.count) + assert _Z(sys) == response.count + _P(sys) plt.figure(); - count = ct.nyquist_plot(sys * 3) - plt.title("Unstable system; encirclements = %d" % count) - assert _Z(sys * 3) == count + _P(sys * 3) + response = ct.nyquist_response(sys * 3) + response.plot() + plt.title("Unstable system; encirclements = %d" %response.count) + assert _Z(sys * 3) == response.count + _P(sys * 3) # System with pole at the origin sys = ct.tf([3], [1, 2, 2, 1, 0]) plt.figure(); - count = ct.nyquist_plot(sys) - plt.title("Pole at the origin; encirclements = %d" % count) - assert _Z(sys) == count + _P(sys) + response = ct.nyquist_response(sys) + response.plot() + plt.title("Pole at the origin; encirclements = %d" %response.count) + assert _Z(sys) == response.count + _P(sys) # Non-integer number of encirclements plt.figure(); sys = 1 / (s**2 + s + 1) with pytest.warns(UserWarning, match="encirclements was a non-integer"): - count = ct.nyquist_plot(sys, omega_limits=[0.5, 1e3]) + response = ct.nyquist_response(sys, omega_limits=[0.5, 1e3]) with warnings.catch_warnings(): warnings.simplefilter("error") # strip out matrix warnings - count = ct.nyquist_plot( + response = ct.nyquist_response( sys, omega_limits=[0.5, 1e3], encirclement_threshold=0.2) - plt.title("Non-integer number of encirclements [%g]" % count) + response.plot() + plt.title("Non-integer number of encirclements [%g]" %response.count) @pytest.fixture @@ -245,16 +255,17 @@ def indentsys(): def test_nyquist_indent_default(indentsys): plt.figure(); - count = ct.nyquist_plot(indentsys) + response = ct.nyquist_response(indentsys) + response.plot() plt.title("Pole at origin; indent_radius=default") - assert _Z(indentsys) == count + _P(indentsys) + assert _Z(indentsys) == response.count + _P(indentsys) def test_nyquist_indent_dont(indentsys): # first value of default omega vector was 0.1, replaced by 0. for contour # indent_radius is larger than 0.1 -> no extra quater circle around origin with pytest.warns(UserWarning, match="encirclements does not match"): - count, contour = ct.nyquist_plot( + count, contour = ct.nyquist_response( indentsys, omega=[0, 0.2, 0.3, 0.4], indent_radius=.1007, plot=False, return_contour=True) np.testing.assert_allclose(contour[0], .1007+0.j) @@ -264,8 +275,10 @@ def test_nyquist_indent_dont(indentsys): def test_nyquist_indent_do(indentsys): plt.figure(); - count, contour = ct.nyquist_plot( + response = ct.nyquist_response( indentsys, indent_radius=0.01, return_contour=True) + count, contour = response + response.plot() plt.title("Pole at origin; indent_radius=0.01; encirclements = %d" % count) assert _Z(indentsys) == count + _P(indentsys) # indent radius is smaller than the start of the default omega vector @@ -276,10 +289,12 @@ def test_nyquist_indent_do(indentsys): def test_nyquist_indent_left(indentsys): plt.figure(); - count = ct.nyquist_plot(indentsys, indent_direction='left') + response = ct.nyquist_response(indentsys, indent_direction='left') + response.plot() plt.title( - "Pole at origin; indent_direction='left'; encirclements = %d" % count) - assert _Z(indentsys) == count + _P(indentsys, indent='left') + "Pole at origin; indent_direction='left'; encirclements = %d" % + response.count) + assert _Z(indentsys) == response.count + _P(indentsys, indent='left') def test_nyquist_indent_im(): @@ -288,25 +303,30 @@ def test_nyquist_indent_im(): # Imaginary poles with standard indentation plt.figure(); - count = ct.nyquist_plot(sys) - plt.title("Imaginary poles; encirclements = %d" % count) - assert _Z(sys) == count + _P(sys) + response = ct.nyquist_response(sys) + response.plot() + plt.title("Imaginary poles; encirclements = %d" % response.count) + assert _Z(sys) == response.count + _P(sys) # Imaginary poles with indentation to the left plt.figure(); - count = ct.nyquist_plot(sys, indent_direction='left', label_freq=300) + response = ct.nyquist_response(sys, indent_direction='left', label_freq=300) + response.plot() plt.title( - "Imaginary poles; indent_direction='left'; encirclements = %d" % count) - assert _Z(sys) == count + _P(sys, indent='left') + "Imaginary poles; indent_direction='left'; encirclements = %d" % + response.count) + assert _Z(sys) == response.count + _P(sys, indent='left') # Imaginary poles with no indentation plt.figure(); with pytest.warns(UserWarning, match="encirclements does not match"): - count = ct.nyquist_plot( + response = ct.nyquist_response( sys, np.linspace(0, 1e3, 1000), indent_direction='none') + response.plot() plt.title( - "Imaginary poles; indent_direction='none'; encirclements = %d" % count) - assert _Z(sys) == count + _P(sys) + "Imaginary poles; indent_direction='none'; encirclements = %d" % + response.count) + assert _Z(sys) == response.count + _P(sys) def test_nyquist_exceptions(): @@ -365,26 +385,26 @@ def test_nyquist_legacy(): sys = (0.02 * s**3 - 0.1 * s) / (s**4 + s**3 + s**2 + 0.25 * s + 0.04) with pytest.warns(UserWarning, match="indented contour may miss"): - count = ct.nyquist_plot(sys) + response = ct.nyquist_plot(sys) def test_discrete_nyquist(): # Make sure we can handle discrete time systems with negative poles sys = ct.tf(1, [1, -0.1], dt=1) * ct.tf(1, [1, 0.1], dt=1) - ct.nyquist_plot(sys, plot=False) + ct.nyquist_response(sys, plot=False) # system with a pole at the origin sys = ct.zpk([1,], [.3, 0], 1, dt=True) - ct.nyquist_plot(sys, plot=False) + ct.nyquist_response(sys) sys = ct.zpk([1,], [0], 1, dt=True) - ct.nyquist_plot(sys, plot=False) + ct.nyquist_response(sys) # only a pole at the origin sys = ct.zpk([], [0], 2, dt=True) - ct.nyquist_plot(sys, plot=False) + ct.nyquist_response(sys) # pole at zero (pure delay) sys = ct.zpk([], [1], 1, dt=True) - ct.nyquist_plot(sys, plot=False) + ct.nyquist_response(sys) if __name__ == "__main__": @@ -432,15 +452,17 @@ def test_discrete_nyquist(): plt.figure() plt.title("Poles: %s" % np.array2string(sys.poles(), precision=2, separator=',')) - count = ct.nyquist_plot(sys) - assert _Z(sys) == count + _P(sys) + response = ct.nyquist_response(sys) + response.plot() + assert _Z(sys) == response.count + _P(sys) print("Discrete time systems") sys = ct.c2d(sys, 0.01) plt.figure() plt.title("Discrete-time; poles: %s" % np.array2string(sys.poles(), precision=2, separator=',')) - count = ct.nyquist_plot(sys) + response = ct.nyquist_response(sys) + response.plot() diff --git a/examples/bode-and-nyquist-plots.ipynb b/examples/bode-and-nyquist-plots.ipynb index 4568f8cd0..b31d39eea 100644 --- a/examples/bode-and-nyquist-plots.ipynb +++ b/examples/bode-and-nyquist-plots.ipynb @@ -1100,7 +1100,7 @@ ], "source": [ "fig = plt.figure()\n", - "mag, phase, omega = ct.bode_plot(pt1_w001rad, Hz=False)" + "out = ct.bode_plot(pt1_w001rad, Hz=False)" ] }, { @@ -1910,7 +1910,7 @@ ], "source": [ "fig = plt.figure()\n", - "mag, phase, omega = ct.bode_plot(pt1_w001rads, Hz=False)" + "out = ct.bode_plot(pt1_w001rads, Hz=False)" ] }, { @@ -2720,7 +2720,7 @@ ], "source": [ "fig = plt.figure()\n", - "mag, phase, omega = ct.bode_plot(pt1_w001hz, Hz=True)" + "out = ct.bode_plot(pt1_w001hz, Hz=True)" ] }, { @@ -3530,7 +3530,7 @@ ], "source": [ "fig = plt.figure()\n", - "mag, phase, omega = ct.bode_plot(pt1_w001hzs)" + "out = ct.bode_plot(pt1_w001hzs)" ] }, { @@ -4341,7 +4341,7 @@ "source": [ "ct.config.bode_number_of_samples = 1000\n", "fig = plt.figure()\n", - "mag, phase, omega = ct.bode_plot([pt1_w001hz, pt1_w001hzs])" + "out = ct.bode_plot([pt1_w001hz, pt1_w001hzs])" ] }, { @@ -5151,7 +5151,7 @@ ], "source": [ "fig = plt.figure()\n", - "mag, phase, omega = ct.bode_plot([pt1_w001hzi, pt1_w001hzis])" + "out = ct.bode_plot([pt1_w001hzi, pt1_w001hzis])" ] }, { @@ -5963,7 +5963,7 @@ "ct.config.bode_feature_periphery_decade = 1.\n", "ct.config.bode_number_of_samples = 10000\n", "fig = plt.figure()\n", - "mag, phase, omega = ct.bode_plot([pt1_w001hzi, pt1_w001hzis, pt2_w001hz, pt2_w001hzs, pt5hz, pt5s, pt5sh])" + "out = ct.bode_plot([pt1_w001hzi, pt1_w001hzis, pt2_w001hz, pt2_w001hzs, pt5hz, pt5s, pt5sh])" ] }, { @@ -6774,7 +6774,7 @@ "source": [ "ct.config.bode_feature_periphery_decade = 3.5\n", "fig = plt.figure()\n", - "mag, phase, omega = ct.bode_plot([pt1_w001hzi, pt1_w001hzis], Hz=True)" + "out = ct.bode_plot([pt1_w001hzi, pt1_w001hzis], Hz=True)" ] }, { @@ -7584,7 +7584,7 @@ ], "source": [ "fig = plt.figure()\n", - "mag, phase, omega = ct.bode_plot([pt1_w001hzi, pt1_w001hzis], deg=False)" + "out = ct.bode_plot([pt1_w001hzi, pt1_w001hzis], deg=False)" ] }, { @@ -8396,7 +8396,7 @@ "ct.config.bode_feature_periphery_decade = 1.\n", "ct.config.bode_number_of_samples = 1000\n", "fig = plt.figure()\n", - "mag, phase, omega = ct.bode_plot([pt1_w001hzi, pt1_w001hzis, pt2_w001hz, pt2_w001hzs, pt5hz, pt5s, pt5sh], Hz=True,\n", + "out = ct.bode_plot([pt1_w001hzi, pt1_w001hzis, pt2_w001hz, pt2_w001hzs, pt5hz, pt5s, pt5sh], Hz=True,\n", " omega_limits=(1.,1000.))" ] }, @@ -9200,7 +9200,7 @@ ], "source": [ "fig = plt.figure()\n", - "mag, phase, omega = ct.bode_plot([pt1_w001hzi, pt1_w001hzis, pt2_w001hz, pt2_w001hzs, pt5hz, pt5s, pt5sh], Hz=False,\n", + "out = ct.bode_plot([pt1_w001hzi, pt1_w001hzis, pt2_w001hz, pt2_w001hzs, pt5hz, pt5s, pt5sh], Hz=False,\n", " omega_limits=(1.,1000.))" ] }, diff --git a/examples/singular-values-plot.ipynb b/examples/singular-values-plot.ipynb index c95ff3f67..f126c6c3f 100644 --- a/examples/singular-values-plot.ipynb +++ b/examples/singular-values-plot.ipynb @@ -1124,7 +1124,9 @@ "source": [ "omega = np.logspace(-4, 1, 1000)\n", "plt.figure()\n", - "sigma_ct, omega_ct = ct.freqplot.singular_values_plot(G, omega);" + "response = ct.freqplot.singular_values_response(G, omega)\n", + "sigma_ct, omega_ct = response\n", + "response.plot();" ] }, { @@ -2116,7 +2118,9 @@ ], "source": [ "plt.figure()\n", - "sigma_dt, omega_dt = ct.freqplot.singular_values_plot(Gd, omega);" + "response = ct.freqplot.singular_values_response(Gd, omega)\n", + "sigma_dt, omega_dt = response\n", + "response.plot();" ] }, { From 83c9e4e8e80fe09d5b5797dc25cd247ff0fa9f86 Mon Sep 17 00:00:00 2001 From: Richard Murray Date: Tue, 18 Jul 2023 22:43:28 -0700 Subject: [PATCH 11/17] refactoring of describing_function_plot in response/plot + updated unit tests --- control/descfcn.py | 162 ++++++++++++++++++++++++++++------ control/tests/descfcn_test.py | 42 +++++++-- control/tests/kwargs_test.py | 3 + 3 files changed, 173 insertions(+), 34 deletions(-) diff --git a/control/descfcn.py b/control/descfcn.py index a5cf0638d..505d716d5 100644 --- a/control/descfcn.py +++ b/control/descfcn.py @@ -19,10 +19,12 @@ from warnings import warn from .freqplot import nyquist_response +from . import config __all__ = ['describing_function', 'describing_function_plot', - 'DescribingFunctionNonlinearity', 'friction_backlash_nonlinearity', - 'relay_hysteresis_nonlinearity', 'saturation_nonlinearity'] + 'describing_function_response', 'DescribingFunctionNonlinearity', + 'friction_backlash_nonlinearity', 'relay_hysteresis_nonlinearity', + 'saturation_nonlinearity'] # Class for nonlinearities with a built-in describing function class DescribingFunctionNonlinearity(): @@ -205,14 +207,41 @@ def describing_function( # Return the values in the same shape as they were requested return retdf +# +# Describing function response/plot +# -def describing_function_plot( - H, F, A, omega=None, refine=True, label="%5.2g @ %-5.2g", - warn=None, **kwargs): - """Plot a Nyquist plot with a describing function for a nonlinear system. +# Simple class to store the describing function response +class DescribingFunctionResponse: + def __init__(self, response, N_vals, positions, intersections): + self.response = response + self.N_vals = N_vals + self.positions = positions + self.intersections = intersections + + def plot(self, **kwargs): + return describing_function_plot(self, **kwargs) + + # Implement iter, getitem, len to allow recovering the intersections + def __iter__(self): + return iter(self.intersections) + + def __getitem__(self, index): + return list(self.__iter__())[index] + + def __len__(self): + return len(self.intersections) - This function generates a Nyquist plot for a closed loop system consisting - of a linear system with a static nonlinear function in the feedback path. + +# Compute the describing function response + intersections +def describing_function_response( + H, F, A, omega=None, refine=True, warn_nyquist=None, + plot=False, check_kwargs=True, **kwargs): + """Compute the describing function response of a system. + + This function uses describing function analysis to analyze a closed + loop system consisting of a linear system with a static nonlinear + function in the feedback path. Parameters ---------- @@ -226,10 +255,7 @@ def describing_function_plot( List of amplitudes to be used for the describing function plot. omega : list, optional List of frequencies to be used for the linear system Nyquist curve. - label : str, optional - Formatting string used to label intersection points on the Nyquist - plot. Defaults to "%5.2g @ %-5.2g". Set to `None` to omit labels. - warn : bool, optional + warn_nyquist : bool, optional Set to True to turn on warnings generated by `nyquist_plot` or False to turn off warnings. If not set (or set to None), warnings are turned off if omega is specified, otherwise they are turned on. @@ -249,31 +275,27 @@ def describing_function_plot( >>> H_simple = ct.tf([8], [1, 2, 2, 1]) >>> F_saturation = ct.saturation_nonlinearity(1) >>> amp = np.linspace(1, 4, 10) - >>> ct.describing_function_plot(H_simple, F_saturation, amp) # doctest: +SKIP + >>> ct.describing_function_response(H_simple, F_saturation, amp) # doctest: +SKIP [(3.343844998258643, 1.4142293090899216)] """ # Decide whether to turn on warnings or not - if warn is None: + if warn_nyquist is None: # Turn warnings on unless omega was specified - warn = omega is None + warn_nyquist = omega is None # Start by drawing a Nyquist curve response = nyquist_response( - H, omega, warn_encirclements=warn, warn_nyquist=warn, - check_kwargs=False, **kwargs) - response.plot(**kwargs) + H, omega, warn_encirclements=warn_nyquist, warn_nyquist=warn_nyquist, + check_kwargs=check_kwargs, **kwargs) H_omega, H_vals = response.contour.imag, H(response.contour) # Compute the describing function df = describing_function(F, A) N_vals = -1/df - # Now add the describing function curve to the plot - plt.plot(N_vals.real, N_vals.imag) - # Look for intersection points - intersections = [] + positions, intersections = [], [] for i in range(N_vals.size - 1): for j in range(H_vals.size - 1): intersect = _find_intersection( @@ -306,17 +328,99 @@ def _cost(x): else: a_final, omega_final = res.x[0], res.x[1] - # Add labels to the intersection points - if isinstance(label, str): - pos = H(1j * omega_final) - plt.text(pos.real, pos.imag, label % (a_final, omega_final)) - elif label is not None or label is not False: - raise ValueError("label must be formatting string or None") + pos = H(1j * omega_final) # Save the final estimate + positions.append(pos) intersections.append((a_final, omega_final)) - return intersections + return DescribingFunctionResponse( + response, N_vals, positions, intersections) + + +def describing_function_plot( + *sysdata, label="%5.2g @ %-5.2g", **kwargs): + """Plot a Nyquist plot with a describing function for a nonlinear system. + + This function generates a Nyquist plot for a closed loop system + consisting of a linear system with a static nonlinear function in the + feedback path. + + Parameters + ---------- + H : LTI system + Linear time-invariant (LTI) system (state space, transfer function, or + FRD) + F : static nonlinear function + A static nonlinearity, either a scalar function or a single-input, + single-output, static input/output system. + A : list + List of amplitudes to be used for the describing function plot. + omega : list, optional + List of frequencies to be used for the linear system Nyquist curve. + refine : bool, optional + If True (default), refine the location of the intersection of the + Nyquist curve for the linear system and the describing function to + determine the intersection point + label : str, optional + Formatting string used to label intersection points on the Nyquist + plot. Defaults to "%5.2g @ %-5.2g". Set to `None` to omit labels. + + Returns + ------- + intersections : 1D array of 2-tuples or None + A list of all amplitudes and frequencies in which :math:`H(j\\omega) + N(a) = -1`, where :math:`N(a)` is the describing function associated + with `F`, or `None` if there are no such points. Each pair represents + a potential limit cycle for the closed loop system with amplitude + given by the first value of the tuple and frequency given by the + second value. + + Examples + -------- + >>> H_simple = ct.tf([8], [1, 2, 2, 1]) + >>> F_saturation = ct.saturation_nonlinearity(1) + >>> amp = np.linspace(1, 4, 10) + >>> ct.describing_function_plot(H_simple, F_saturation, amp) # doctest: +SKIP + [(3.343844998258643, 1.4142293090899216)] + + """ + # Process keywords + warn_nyquist = config._process_legacy_keyword( + kwargs, 'warn', 'warn_nyquist', kwargs.pop('warn_nyquist', None)) + + if label not in (False, None) and not isinstance(label, str): + raise ValueError("label must be formatting string, False, or None") + + # Get the describing function response + if len(sysdata) == 3: + sysdata = sysdata + (None, ) # set omega to default value + if len(sysdata) == 4: + dfresp = describing_function_response( + *sysdata, refine=kwargs.pop('refine', True), + warn_nyquist=warn_nyquist) + elif len(sysdata) == 1: + dfresp = sysdata[0] + else: + raise TypeError("1, 3, or 4 position arguments required") + + # Create a list of lines for the output + out = np.empty(2, dtype=object) + + # Plot the Nyquist response + out[0] = dfresp.response.plot(**kwargs)[0] + + # Add the describing function curve to the plot + lines = plt.plot(dfresp.N_vals.real, dfresp.N_vals.imag) + out[1] = lines + + # Label the intersection points + if label: + for pos, (a, omega) in zip(dfresp.positions, dfresp.intersections): + # Add labels to the intersection points + plt.text(pos.real, pos.imag, label % (a, omega)) + + return out # Utility function to figure out whether two line segments intersection diff --git a/control/tests/descfcn_test.py b/control/tests/descfcn_test.py index 796ad9034..7b998d084 100644 --- a/control/tests/descfcn_test.py +++ b/control/tests/descfcn_test.py @@ -12,6 +12,7 @@ import numpy as np import control as ct import math +import matplotlib.pyplot as plt from control.descfcn import saturation_nonlinearity, \ friction_backlash_nonlinearity, relay_hysteresis_nonlinearity @@ -137,7 +138,7 @@ def test_describing_function(fcn, amin, amax): ct.describing_function(fcn, -1) -def test_describing_function_plot(): +def test_describing_function_response(): # Simple linear system with at most 1 intersection H_simple = ct.tf([1], [1, 2, 2, 1]) omega = np.logspace(-1, 2, 100) @@ -147,12 +148,12 @@ def test_describing_function_plot(): amp = np.linspace(1, 4, 10) # No intersection - xsects = ct.describing_function_plot(H_simple, F_saturation, amp, omega) - assert xsects == [] + xsects = ct.describing_function_response(H_simple, F_saturation, amp, omega) + assert len(xsects) == 0 # One intersection H_larger = H_simple * 8 - xsects = ct.describing_function_plot(H_larger, F_saturation, amp, omega) + xsects = ct.describing_function_response(H_larger, F_saturation, amp, omega) for a, w in xsects: np.testing.assert_almost_equal( H_larger(1j*w), @@ -163,12 +164,38 @@ def test_describing_function_plot(): omega = np.logspace(-1, 3, 50) F_backlash = ct.descfcn.friction_backlash_nonlinearity(1) amp = np.linspace(0.6, 5, 50) - xsects = ct.describing_function_plot(H_multiple, F_backlash, amp, omega) + xsects = ct.describing_function_response(H_multiple, F_backlash, amp, omega) for a, w in xsects: np.testing.assert_almost_equal( -1/ct.describing_function(F_backlash, a), H_multiple(1j*w), decimal=5) + +def test_describing_function_plot(): + # Simple linear system with at most 1 intersection + H_larger = ct.tf([1], [1, 2, 2, 1]) * 8 + omega = np.logspace(-1, 2, 100) + + # Saturation nonlinearity + F_saturation = ct.descfcn.saturation_nonlinearity(1) + amp = np.linspace(1, 4, 10) + + # Plot via response + plt.clf() # clear axes + response = ct.describing_function_response( + H_larger, F_saturation, amp, omega) + assert len(response.intersections) == 1 + assert len(plt.gcf().get_axes()) == 0 # make sure there is no plot + + out = response.plot() + assert len(plt.gcf().get_axes()) == 1 # make sure there is a plot + assert len(out[0]) == 4 and len(out[1]) == 1 + + # Call plot directly + out = ct.describing_function_plot(H_larger, F_saturation, amp, omega) + assert len(out[0]) == 4 and len(out[1]) == 1 + + def test_describing_function_exceptions(): # Describing function with non-zero bias with pytest.warns(UserWarning, match="asymmetric"): @@ -194,3 +221,8 @@ def test_describing_function_exceptions(): amp = np.linspace(1, 4, 10) with pytest.raises(ValueError, match="formatting string"): ct.describing_function_plot(H_simple, F_saturation, amp, label=1) + + # Unrecognized keyword + with pytest.raises(TypeError, match="unrecognized keyword"): + ct.describing_function_response( + H_simple, F_saturation, amp, None, unknown=None) diff --git a/control/tests/kwargs_test.py b/control/tests/kwargs_test.py index 5e5cc71be..b3e27fe80 100644 --- a/control/tests/kwargs_test.py +++ b/control/tests/kwargs_test.py @@ -27,6 +27,7 @@ import control.tests.stochsys_test as stochsys_test import control.tests.trdata_test as trdata_test import control.tests.timeplot_test as timeplot_test +import control.tests.descfcn_test as descfcn_test @pytest.mark.parametrize("module, prefix", [ (control, ""), (control.flatsys, "flatsys."), (control.optimal, "optimal.") @@ -210,6 +211,8 @@ def test_response_plot_kwargs(data_fcn, plot_fcn, mimo): 'create_estimator_iosystem': stochsys_test.test_estimator_errors, 'create_statefbk_iosystem': statefbk_test.TestStatefbk.test_statefbk_errors, 'describing_function_plot': test_matplotlib_kwargs, + 'describing_function_response': + descfcn_test.test_describing_function_exceptions, 'dlqe': test_unrecognized_kwargs, 'dlqr': test_unrecognized_kwargs, 'drss': test_unrecognized_kwargs, From 9bc5d71e70dbbb2eef57e8e3ca2eb011044f6d22 Mon Sep 17 00:00:00 2001 From: Richard Murray Date: Thu, 20 Jul 2023 19:01:56 -0700 Subject: [PATCH 12/17] regularize sysdata/list processing + small refactor + updated example --- control/freqplot.py | 73 +- control/lti.py | 11 +- control/tests/freqplot_test.py | 24 + examples/bode-and-nyquist-plots.ipynb | 11963 +++++++++++++----------- 4 files changed, 6644 insertions(+), 5427 deletions(-) diff --git a/control/freqplot.py b/control/freqplot.py index 7762db052..2025ab71d 100644 --- a/control/freqplot.py +++ b/control/freqplot.py @@ -5,10 +5,10 @@ # # Functionality to add # [ ] Get rid of this long header (need some common, documented convention) -# [ ] Add mechanisms for storing/plotting margins? (currently forces FRD) -# [ ] Allow line colors/styles to be set in plot() command (also time plots) -# [ ] Allow bode or nyquist style plots from plot() -# [ ] Allow nyquist_response() to generate the response curve (?) +# [x] Add mechanisms for storing/plotting margins? (currently forces FRD) +# [?] Allow line colors/styles to be set in plot() command (also time plots) +# [x] Allow bode or nyquist style plots from plot() +# [i] Allow nyquist_response() to generate the response curve (?) # [i] Allow MIMO frequency plots (w/ mag/phase subplots a la MATLAB) # [i] Update sisotool to use ax= # [i] Create __main__ in freqplot_test to view results (a la timeplot_test) @@ -17,11 +17,11 @@ # [i] Re-implement including of gain/phase margin in the title (?) # [i] Change gangof4 to use bode_plot(plot_phase=False) w/ proper labels # [ ] Allow use of subplot labels instead of output/input subtitles -# [ ] Add line labels to gangof4 -# [ ] Update FRD to allow nyquist_response contours -# [ ] Allow frequency range to be overridden in bode_plot -# [ ] Unit tests for discrete time systems with different sample times -# [ ] Check examples/bode-and-nyquist-plots.ipynb for differences +# [i] Add line labels to gangof4 [done by via bode_plot()] +# [i] Allow frequency range to be overridden in bode_plot +# [i] Unit tests for discrete time systems with different sample times +# [c] Check examples/bode-and-nyquist-plots.ipynb for differences +# [ ] Add unit tests for ct.config.defaults['freqplot_number_of_samples'] # # This file contains some standard control system plots: Bode plots, @@ -704,7 +704,7 @@ def _make_line_label(response, output_index, input_index): # Get the frequencies and convert to Hz, if needed omega_plot = omega_sys / (2 * math.pi) if Hz else omega_sys if response.isdtime(strict=True): - nyq_freq = 0.5 /response.dt if Hz else math.pi / response.dt + nyq_freq = (0.5/response.dt) if Hz else (math.pi/response.dt) # Save the magnitude and phase to plot mag_plot = 20 * np.log10(mag[i, j]) if dB else mag[i, j] @@ -1163,7 +1163,7 @@ def plot(self, *args, **kwargs): def nyquist_response( - syslist, omega=None, plot=None, omega_limits=None, omega_num=None, + sysdata, omega=None, plot=None, omega_limits=None, omega_num=None, label_freq=0, color=None, return_contour=False, check_kwargs=True, warn_encirclements=True, warn_nyquist=True, **kwargs): """Nyquist response for a system. @@ -1179,7 +1179,7 @@ def nyquist_response( Parameters ---------- - syslist : list of LTI + sysdata : LTI or list of LTI List of linear input/output systems (single system is OK). Nyquist curves for each system are plotted on the same graph. @@ -1283,9 +1283,8 @@ def nyquist_response( if check_kwargs and kwargs: raise TypeError("unrecognized keywords: ", str(kwargs)) - # If argument was a singleton, turn it into a tuple - if not isinstance(syslist, (list, tuple)): - syslist = (syslist,) + # Convert the first argument to a list + syslist = sysdata if isinstance(sysdata, (list, tuple)) else [sysdata] # Determine the range of frequencies to use, based on args/features omega, omega_range_given = _determine_omega_vector( @@ -1499,17 +1498,15 @@ def nyquist_response( count, contour, resp, sys.dt, sysname=sysname, return_contour=return_contour)) - # Return response - if len(responses) == 1: # TODO: update to match input type - return responses[0] - else: + if isinstance(sysdata, (list, tuple)): return NyquistResponseList(responses) + else: + return responses[0] def nyquist_plot( - data, omega=None, plot=None, omega_limits=None, omega_num=None, - label_freq=0, color=None, return_contour=None, title=None, - legend_loc='upper right', **kwargs): + data, omega=None, plot=None, label_freq=0, color=None, + return_contour=None, title=None, legend_loc='upper right', **kwargs): """Nyquist plot for a system. Generates a Nyquist plot for the system over a (optional) frequency @@ -1677,8 +1674,6 @@ def nyquist_plot( """ # Get values for params (and pop from list to allow keyword use in plot) - omega_num_given = omega_num is not None - omega_num = config._get_param('freqplot', 'number_of_samples', omega_num) arrows = config._get_param( 'nyquist', 'arrows', kwargs, _nyquist_defaults, pop=True) arrow_size = config._get_param( @@ -1724,18 +1719,21 @@ def _parse_linestyle(style_name, allow_false=False): else: raise ValueError("unknown or unsupported arrow location") + # Set the arrow style + if arrow_style is None: + arrow_style = mpl.patches.ArrowStyle( + 'simple', head_width=arrow_size, head_length=arrow_size) + # If argument was a singleton, turn it into a tuple if not isinstance(data, (list, tuple)): data = (data,) # If we are passed a list of systems, compute response first - # If we were passed a list of systems, convert to data if all([isinstance( sys, (StateSpace, TransferFunction, FrequencyResponseData)) for sys in data]): nyquist_responses = nyquist_response( - data, omega=omega, omega_limits=omega_limits, omega_num=omega_num, - check_kwargs=False, **kwargs) + data, omega=omega, check_kwargs=False, **kwargs) if not isinstance(nyquist_responses, list): nyquist_responses = [nyquist_responses] else: @@ -1763,11 +1761,6 @@ def _parse_linestyle(style_name, allow_false=False): for i in range(out.shape[0]): out[i] = [] # unique list in each element - # Set the arrow style - if arrow_style is None: - arrow_style = mpl.patches.ArrowStyle( - 'simple', head_width=arrow_size, head_length=arrow_size) - for idx, response in enumerate(nyquist_responses): resp = response.response if response.dt in [0, None]: @@ -1919,6 +1912,7 @@ def _parse_linestyle(style_name, allow_false=False): title = "Nyquist plot for " + ", ".join(labels) fig.suptitle(title) + # Legacy return pocessing if plot is True or return_contour is not None: if len(data) == 1: counts, contours = counts[0], contours[0] @@ -2134,7 +2128,7 @@ def gangof4_plot(P, C, omega=None, **kwargs): # Singular values plot # def singular_values_response( - sys, omega=None, omega_limits=None, omega_num=None, Hz=False): + sysdata, omega=None, omega_limits=None, omega_num=None, Hz=False): """Singular value response for a system. Computes the singular values for a system or list of systems over @@ -2173,8 +2167,8 @@ def singular_values_response( >>> response = ct.singular_values_response(G, omega=omegas) """ - # If argument was a singleton, turn it into a tuple - syslist = sys if isinstance(sys, (list, tuple)) else (sys,) + # Convert the first argument to a list + syslist = sysdata if isinstance(sysdata, (list, tuple)) else [sysdata] if any([not isinstance(sys, LTI) for sys in syslist]): ValueError("singular values can only be computed for LTI systems") @@ -2201,8 +2195,7 @@ def singular_values_response( sysname=response.sysname, plot_type='svplot', title=f"Singular values for {response.sysname}")) - # Return the responses in the same form that we received the systems - if isinstance(sys, (list, tuple)): + if isinstance(sysdata, (list, tuple)): return FrequencyResponseList(svd_responses) else: return svd_responses[0] @@ -2554,20 +2547,20 @@ def _default_frequency_range(syslist, Hz=None, number_of_samples=None, if np.any(toreplace): features_ = features_[~toreplace] elif sys.isdtime(strict=True): - fn = math.pi * 1. / sys.dt + fn = math.pi / sys.dt # TODO: What distance to the Nyquist frequency is appropriate? freq_interesting.append(fn * 0.9) features_ = np.concatenate((sys.poles(), sys.zeros())) # Get rid of poles and zeros on the real axis (imag==0) - # * origin and real < 0 + # * origin and real < 0 # * at 1.: would result in omega=0. (logaritmic plot!) toreplace = np.isclose(features_.imag, 0.0) & ( (features_.real <= 0.) | (np.abs(features_.real - 1.0) < 1.e-10)) if np.any(toreplace): features_ = features_[~toreplace] - # TODO: improve + # TODO: improve (mapping pack to continuous time) features_ = np.abs(np.log(features_) / (1.j * sys.dt)) else: # TODO diff --git a/control/lti.py b/control/lti.py index d7355395d..81334f869 100644 --- a/control/lti.py +++ b/control/lti.py @@ -372,7 +372,7 @@ def evalfr(sys, x, squeeze=None): def frequency_response( - sys, omega=None, omega_limits=None, omega_num=None, + sysdata, omega=None, omega_limits=None, omega_num=None, Hz=None, squeeze=None): """Frequency response of an LTI system at multiple angular frequencies. @@ -382,7 +382,7 @@ def frequency_response( Parameters ---------- - sys: LTI system or list of LTI systems + sysdata: LTI system or list of LTI systems Linear system(s) for which frequency response is computed. omega : float or 1D array_like, optional A list of frequencies in radians/sec at which the system should be @@ -452,8 +452,11 @@ def frequency_response( """ from .freqplot import _determine_omega_vector + # Process keyword arguments + omega_num = config._get_param('freqplot', 'number_of_samples', omega_num) + # Convert the first argument to a list - syslist = sys if isinstance(sys, (list, tuple)) else [sys] + syslist = sysdata if isinstance(sysdata, (list, tuple)) else [sysdata] # Get the common set of frequencies to use omega_syslist, omega_range_given = _determine_omega_vector( @@ -472,7 +475,7 @@ def frequency_response( # Compute the frequency response responses.append(sys_.frequency_response(omega_sys, squeeze=squeeze)) - if isinstance(sys, (list, tuple)): + if isinstance(sysdata, (list, tuple)): from .freqplot import FrequencyResponseList return FrequencyResponseList(responses) else: diff --git a/control/tests/freqplot_test.py b/control/tests/freqplot_test.py index 9299181b0..f09d5617d 100644 --- a/control/tests/freqplot_test.py +++ b/control/tests/freqplot_test.py @@ -179,6 +179,30 @@ def test_gangof4_plots(savefigs=False): if savefigs: plt.savefig('freqplot-gangof4.png') +@pytest.mark.parametrize("response_cmd, return_type", [ + (ct.frequency_response, ct.FrequencyResponseData), + (ct.nyquist_response, ct.freqplot.NyquistResponseData), + (ct.singular_values_response, ct.FrequencyResponseData), +]) +def test_first_arg_listable(response_cmd, return_type): + sys = ct.rss(2, 1, 1) + + # If we pass a single system, should get back a single system + result = response_cmd(sys) + assert isinstance(result, return_type) + + # If we pass a list of systems, we should get back a list + result = response_cmd([sys, sys, sys]) + assert isinstance(result, list) + assert len(result) == 3 + assert all([isinstance(item, return_type) for item in result]) + + # If we pass a singleton list, we should get back a list + result = response_cmd([sys]) + assert isinstance(result, list) + assert len(result) == 1 + assert isinstance(result[0], return_type) + if __name__ == "__main__": # diff --git a/examples/bode-and-nyquist-plots.ipynb b/examples/bode-and-nyquist-plots.ipynb index b31d39eea..6ac74f34e 100644 --- a/examples/bode-and-nyquist-plots.ipynb +++ b/examples/bode-and-nyquist-plots.ipynb @@ -1,5 +1,15 @@ { "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Bode and Nyquist plot examples\n", + "\n", + "This notebook has various examples of Bode and Nyquist plots showing how these can be \n", + "customized in different ways." + ] + }, { "cell_type": "code", "execution_count": 1, @@ -17,10 +27,8 @@ "metadata": {}, "outputs": [], "source": [ - "%matplotlib nbagg\n", - "# only needed when developing python-control\n", - "%load_ext autoreload\n", - "%autoreload 2" + "# Enable interactive figures (panning and zooming)\n", + "%matplotlib nbagg" ] }, { @@ -41,10 +49,7 @@ "$$\\frac{1}{s + 1}$$" ], "text/plain": [ - "\n", - " 1\n", - "-----\n", - "s + 1" + "TransferFunction(array([1.]), array([1., 1.]))" ] }, "metadata": {}, @@ -56,10 +61,7 @@ "$$\\frac{1}{0.1592 s + 1}$$" ], "text/plain": [ - "\n", - " 1\n", - "------------\n", - "0.1592 s + 1" + "TransferFunction(array([1.]), array([0.15915494, 1. ]))" ] }, "metadata": {}, @@ -71,10 +73,7 @@ "$$\\frac{1}{0.02533 s^2 + 0.1592 s + 1}$$" ], "text/plain": [ - "\n", - " 1\n", - "--------------------------\n", - "0.02533 s^2 + 0.1592 s + 1" + "TransferFunction(array([1.]), array([0.0253303 , 0.15915494, 1. ]))" ] }, "metadata": {}, @@ -86,10 +85,7 @@ "$$\\frac{s}{0.1592 s + 1}$$" ], "text/plain": [ - "\n", - " s\n", - "------------\n", - "0.1592 s + 1" + "TransferFunction(array([1., 0.]), array([0.15915494, 1. ]))" ] }, "metadata": {}, @@ -98,13 +94,11 @@ { "data": { "text/latex": [ - "$$\\frac{1}{1.021e-10 s^5 + 7.122e-08 s^4 + 4.519e-05 s^3 + 0.003067 s^2 + 0.1767 s + 1}$$" + "$$\\frac{1}{1.021 \\times 10^{-10} s^5 + 7.122 \\times 10^{-8} s^4 + 4.519 \\times 10^{-5} s^3 + 0.003067 s^2 + 0.1767 s + 1}$$" ], "text/plain": [ - "\n", - " 1\n", - "---------------------------------------------------------------------------\n", - "1.021e-10 s^5 + 7.122e-08 s^4 + 4.519e-05 s^3 + 0.003067 s^2 + 0.1767 s + 1" + "TransferFunction(array([1.]), array([1.02117614e-10, 7.12202519e-08, 4.51924626e-05, 3.06749883e-03,\n", + " 1.76661987e-01, 1.00000000e+00]))" ] }, "metadata": {}, @@ -119,20 +113,19 @@ "w010hz = 2*sp.pi*10. # 10 Hz\n", "w100hz = 2*sp.pi*100. # 100 Hz\n", "# First order systems\n", - "pt1_w001rad = ct.tf([1.], [1./w001rad, 1.])\n", + "pt1_w001rad = ct.tf([1.], [1./w001rad, 1.], name='pt1_w001rad')\n", "display(pt1_w001rad)\n", - "pt1_w001hz = ct.tf([1.], [1./w001hz, 1.])\n", + "pt1_w001hz = ct.tf([1.], [1./w001hz, 1.], name='pt1_w001hz')\n", "display(pt1_w001hz)\n", - "pt2_w001hz = ct.tf([1.], [1./w001hz**2, 1./w001hz, 1.])\n", + "pt2_w001hz = ct.tf([1.], [1./w001hz**2, 1./w001hz, 1.], name='pt2_w001hz')\n", "display(pt2_w001hz)\n", - "pt1_w001hzi = ct.tf([1., 0.], [1./w001hz, 1.])\n", + "pt1_w001hzi = ct.tf([1., 0.], [1./w001hz, 1.], name='pt1_w001hzi')\n", "display(pt1_w001hzi)\n", "# Second order system\n", - "pt5hz = ct.tf([1.], [1./w001hz, 1.]) * ct.tf([1.], \n", - " [1./w010hz**2, \n", - " 1./w010hz, 1.]) * ct.tf([1.], \n", - " [1./w100hz**2, \n", - " 1./w100hz, 1.])\n", + "pt5hz = ct.tf(\n", + " ct.tf([1.], [1./w001hz, 1.]) *\n", + " ct.tf([1.], [1./w010hz**2, 1./w010hz, 1.]) *\n", + " ct.tf([1.], [1./w100hz**2, 1./w100hz, 1.]), name='pt5hz')\n", "display(pt5hz)\n" ] }, @@ -174,12 +167,7 @@ "$$\\frac{0.0004998 z + 0.0004998}{z - 0.999}\\quad dt = 0.001$$" ], "text/plain": [ - "\n", - "0.0004998 z + 0.0004998\n", - "-----------------------\n", - " z - 0.999\n", - "\n", - "dt = 0.001" + "TransferFunction(array([0.00049975, 0.00049975]), array([ 1. , -0.9990005]), 0.001)" ] }, "metadata": {}, @@ -191,12 +179,7 @@ "$$\\frac{0.003132 z + 0.003132}{z - 0.9937}\\quad dt = 0.001$$" ], "text/plain": [ - "\n", - "0.003132 z + 0.003132\n", - "---------------------\n", - " z - 0.9937\n", - "\n", - "dt = 0.001" + "TransferFunction(array([0.00313175, 0.00313175]), array([ 1. , -0.99373649]), 0.001)" ] }, "metadata": {}, @@ -208,12 +191,7 @@ "$$\\frac{6.264 z - 6.264}{z - 0.9937}\\quad dt = 0.001$$" ], "text/plain": [ - "\n", - "6.264 z - 6.264\n", - "---------------\n", - " z - 0.9937\n", - "\n", - "dt = 0.001" + "TransferFunction(array([ 6.26350792, -6.26350792]), array([ 1. , -0.99373649]), 0.001)" ] }, "metadata": {}, @@ -222,15 +200,10 @@ { "data": { "text/latex": [ - "$$\\frac{9.839e-06 z^2 + 1.968e-05 z + 9.839e-06}{z^2 - 1.994 z + 0.9937}\\quad dt = 0.001$$" + "$$\\frac{9.839 \\times 10^{-6} z^2 + 1.968 \\times 10^{-5} z + 9.839 \\times 10^{-6}}{z^2 - 1.994 z + 0.9937}\\quad dt = 0.001$$" ], "text/plain": [ - "\n", - "9.839e-06 z^2 + 1.968e-05 z + 9.839e-06\n", - "---------------------------------------\n", - " z^2 - 1.994 z + 0.9937\n", - "\n", - "dt = 0.001" + "TransferFunction(array([9.83859843e-06, 1.96771969e-05, 9.83859843e-06]), array([ 1. , -1.9936972 , 0.99373655]), 0.001)" ] }, "metadata": {}, @@ -239,15 +212,12 @@ { "data": { "text/latex": [ - "$$\\frac{2.091e-07 z^5 + 1.046e-06 z^4 + 2.091e-06 z^3 + 2.091e-06 z^2 + 1.046e-06 z + 2.091e-07}{z^5 - 4.205 z^4 + 7.155 z^3 - 6.212 z^2 + 2.78 z - 0.5182}\\quad dt = 0.001$$" + "$$\\frac{2.091 \\times 10^{-7} z^5 + 1.046 \\times 10^{-6} z^4 + 2.091 \\times 10^{-6} z^3 + 2.091 \\times 10^{-6} z^2 + 1.046 \\times 10^{-6} z + 2.091 \\times 10^{-7}}{z^5 - 4.205 z^4 + 7.155 z^3 - 6.212 z^2 + 2.78 z - 0.5182}\\quad dt = 0.001$$" ], "text/plain": [ - "\n", - "2.091e-07 z^5 + 1.046e-06 z^4 + 2.091e-06 z^3 + 2.091e-06 z^2 + 1.046e-06 z + 2.091e-07\n", - "---------------------------------------------------------------------------------------\n", - " z^5 - 4.205 z^4 + 7.155 z^3 - 6.212 z^2 + 2.78 z - 0.5182\n", - "\n", - "dt = 0.001" + "TransferFunction(array([2.09141504e-07, 1.04570752e-06, 2.09141505e-06, 2.09141504e-06,\n", + " 1.04570753e-06, 2.09141504e-07]), array([ 1. , -4.20491439, 7.15468522, -6.21165862, 2.78011819,\n", + " -0.51822371]), 0.001)" ] }, "metadata": {}, @@ -256,15 +226,12 @@ { "data": { "text/latex": [ - "$$\\frac{2.731e-10 z^5 + 1.366e-09 z^4 + 2.731e-09 z^3 + 2.731e-09 z^2 + 1.366e-09 z + 2.731e-10}{z^5 - 4.815 z^4 + 9.286 z^3 - 8.968 z^2 + 4.337 z - 0.8405}\\quad dt = 0.00025$$" + "$$\\frac{2.731 \\times 10^{-10} z^5 + 1.366 \\times 10^{-9} z^4 + 2.731 \\times 10^{-9} z^3 + 2.731 \\times 10^{-9} z^2 + 1.366 \\times 10^{-9} z + 2.731 \\times 10^{-10}}{z^5 - 4.815 z^4 + 9.286 z^3 - 8.968 z^2 + 4.337 z - 0.8405}\\quad dt = 0.00025$$" ], "text/plain": [ - "\n", - "2.731e-10 z^5 + 1.366e-09 z^4 + 2.731e-09 z^3 + 2.731e-09 z^2 + 1.366e-09 z + 2.731e-10\n", - "---------------------------------------------------------------------------------------\n", - " z^5 - 4.815 z^4 + 9.286 z^3 - 8.968 z^2 + 4.337 z - 0.8405\n", - "\n", - "dt = 0.00025" + "TransferFunction(array([2.73131184e-10, 1.36565426e-09, 2.73131739e-09, 2.73130674e-09,\n", + " 1.36565870e-09, 2.73130185e-10]), array([ 1. , -4.81504111, 9.28609659, -8.96760178, 4.33708442,\n", + " -0.84053811]), 0.00025)" ] }, "metadata": {}, @@ -272,17 +239,17 @@ } ], "source": [ - "pt1_w001rads = ct.sample_system(pt1_w001rad, sampleTime, 'tustin')\n", + "pt1_w001rads = ct.sample_system(pt1_w001rad, sampleTime, 'tustin', name='pt1_w001rads')\n", "display(pt1_w001rads)\n", - "pt1_w001hzs = ct.sample_system(pt1_w001hz, sampleTime, 'tustin')\n", + "pt1_w001hzs = ct.sample_system(pt1_w001hz, sampleTime, 'tustin', name='pt1_w001hzs')\n", "display(pt1_w001hzs)\n", - "pt1_w001hzis = ct.sample_system(pt1_w001hzi, sampleTime, 'tustin')\n", + "pt1_w001hzis = ct.sample_system(pt1_w001hzi, sampleTime, 'tustin', name='pt1_w001hzis')\n", "display(pt1_w001hzis)\n", - "pt2_w001hzs = ct.sample_system(pt2_w001hz, sampleTime, 'tustin')\n", + "pt2_w001hzs = ct.sample_system(pt2_w001hz, sampleTime, 'tustin', name='pt2_w001hzs')\n", "display(pt2_w001hzs)\n", - "pt5s = ct.sample_system(pt5hz, sampleTime, 'tustin')\n", + "pt5s = ct.sample_system(pt5hz, sampleTime, 'tustin', name='pt5s')\n", "display(pt5s)\n", - "pt5sh = ct.sample_system(pt5hz, sampleTime/4, 'tustin')\n", + "pt5sh = ct.sample_system(pt5hz, sampleTime/4, 'tustin', name='pt5sh')\n", "display(pt5sh)" ] }, @@ -303,42 +270,46 @@ { "cell_type": "code", "execution_count": 6, - "metadata": {}, + "metadata": { + "scrolled": true + }, "outputs": [ { "data": { "application/javascript": [ "/* Put everything inside the global mpl namespace */\n", + "/* global mpl */\n", "window.mpl = {};\n", "\n", - "\n", - "mpl.get_websocket_type = function() {\n", - " if (typeof(WebSocket) !== 'undefined') {\n", + "mpl.get_websocket_type = function () {\n", + " if (typeof WebSocket !== 'undefined') {\n", " return WebSocket;\n", - " } else if (typeof(MozWebSocket) !== 'undefined') {\n", + " } else if (typeof MozWebSocket !== 'undefined') {\n", " return MozWebSocket;\n", " } else {\n", - " alert('Your browser does not have WebSocket support.' +\n", - " 'Please try Chrome, Safari or Firefox ≥ 6. ' +\n", - " 'Firefox 4 and 5 are also supported but you ' +\n", - " 'have to enable WebSockets in about:config.');\n", - " };\n", - "}\n", + " alert(\n", + " 'Your browser does not have WebSocket support. ' +\n", + " 'Please try Chrome, Safari or Firefox ≥ 6. ' +\n", + " 'Firefox 4 and 5 are also supported but you ' +\n", + " 'have to enable WebSockets in about:config.'\n", + " );\n", + " }\n", + "};\n", "\n", - "mpl.figure = function(figure_id, websocket, ondownload, parent_element) {\n", + "mpl.figure = function (figure_id, websocket, ondownload, parent_element) {\n", " this.id = figure_id;\n", "\n", " this.ws = websocket;\n", "\n", - " this.supports_binary = (this.ws.binaryType != undefined);\n", + " this.supports_binary = this.ws.binaryType !== undefined;\n", "\n", " if (!this.supports_binary) {\n", - " var warnings = document.getElementById(\"mpl-warnings\");\n", + " var warnings = document.getElementById('mpl-warnings');\n", " if (warnings) {\n", " warnings.style.display = 'block';\n", - " warnings.textContent = (\n", - " \"This browser does not support binary websocket messages. \" +\n", - " \"Performance may be slow.\");\n", + " warnings.textContent =\n", + " 'This browser does not support binary websocket messages. ' +\n", + " 'Performance may be slow.';\n", " }\n", " }\n", "\n", @@ -353,11 +324,11 @@ "\n", " this.image_mode = 'full';\n", "\n", - " this.root = $('
');\n", - " this._root_extra_style(this.root)\n", - " this.root.attr('style', 'display: inline-block');\n", + " this.root = document.createElement('div');\n", + " this.root.setAttribute('style', 'display: inline-block');\n", + " this._root_extra_style(this.root);\n", "\n", - " $(parent_element).append(this.root);\n", + " parent_element.appendChild(this.root);\n", "\n", " this._init_header(this);\n", " this._init_canvas(this);\n", @@ -367,285 +338,366 @@ "\n", " this.waiting = false;\n", "\n", - " this.ws.onopen = function () {\n", - " fig.send_message(\"supports_binary\", {value: fig.supports_binary});\n", - " fig.send_message(\"send_image_mode\", {});\n", - " if (mpl.ratio != 1) {\n", - " fig.send_message(\"set_dpi_ratio\", {'dpi_ratio': mpl.ratio});\n", - " }\n", - " fig.send_message(\"refresh\", {});\n", + " this.ws.onopen = function () {\n", + " fig.send_message('supports_binary', { value: fig.supports_binary });\n", + " fig.send_message('send_image_mode', {});\n", + " if (fig.ratio !== 1) {\n", + " fig.send_message('set_device_pixel_ratio', {\n", + " device_pixel_ratio: fig.ratio,\n", + " });\n", " }\n", + " fig.send_message('refresh', {});\n", + " };\n", "\n", - " this.imageObj.onload = function() {\n", - " if (fig.image_mode == 'full') {\n", - " // Full images could contain transparency (where diff images\n", - " // almost always do), so we need to clear the canvas so that\n", - " // there is no ghosting.\n", - " fig.context.clearRect(0, 0, fig.canvas.width, fig.canvas.height);\n", - " }\n", - " fig.context.drawImage(fig.imageObj, 0, 0);\n", - " };\n", + " this.imageObj.onload = function () {\n", + " if (fig.image_mode === 'full') {\n", + " // Full images could contain transparency (where diff images\n", + " // almost always do), so we need to clear the canvas so that\n", + " // there is no ghosting.\n", + " fig.context.clearRect(0, 0, fig.canvas.width, fig.canvas.height);\n", + " }\n", + " fig.context.drawImage(fig.imageObj, 0, 0);\n", + " };\n", "\n", - " this.imageObj.onunload = function() {\n", + " this.imageObj.onunload = function () {\n", " fig.ws.close();\n", - " }\n", + " };\n", "\n", " this.ws.onmessage = this._make_on_message_function(this);\n", "\n", " this.ondownload = ondownload;\n", - "}\n", - "\n", - "mpl.figure.prototype._init_header = function() {\n", - " var titlebar = $(\n", - " '
');\n", - " var titletext = $(\n", - " '
');\n", - " titlebar.append(titletext)\n", - " this.root.append(titlebar);\n", - " this.header = titletext[0];\n", - "}\n", - "\n", - "\n", - "\n", - "mpl.figure.prototype._canvas_extra_style = function(canvas_div) {\n", - "\n", - "}\n", + "};\n", "\n", + "mpl.figure.prototype._init_header = function () {\n", + " var titlebar = document.createElement('div');\n", + " titlebar.classList =\n", + " 'ui-dialog-titlebar ui-widget-header ui-corner-all ui-helper-clearfix';\n", + " var titletext = document.createElement('div');\n", + " titletext.classList = 'ui-dialog-title';\n", + " titletext.setAttribute(\n", + " 'style',\n", + " 'width: 100%; text-align: center; padding: 3px;'\n", + " );\n", + " titlebar.appendChild(titletext);\n", + " this.root.appendChild(titlebar);\n", + " this.header = titletext;\n", + "};\n", "\n", - "mpl.figure.prototype._root_extra_style = function(canvas_div) {\n", + "mpl.figure.prototype._canvas_extra_style = function (_canvas_div) {};\n", "\n", - "}\n", + "mpl.figure.prototype._root_extra_style = function (_canvas_div) {};\n", "\n", - "mpl.figure.prototype._init_canvas = function() {\n", + "mpl.figure.prototype._init_canvas = function () {\n", " var fig = this;\n", "\n", - " var canvas_div = $('
');\n", - "\n", - " canvas_div.attr('style', 'position: relative; clear: both; outline: 0');\n", + " var canvas_div = (this.canvas_div = document.createElement('div'));\n", + " canvas_div.setAttribute(\n", + " 'style',\n", + " 'border: 1px solid #ddd;' +\n", + " 'box-sizing: content-box;' +\n", + " 'clear: both;' +\n", + " 'min-height: 1px;' +\n", + " 'min-width: 1px;' +\n", + " 'outline: 0;' +\n", + " 'overflow: hidden;' +\n", + " 'position: relative;' +\n", + " 'resize: both;'\n", + " );\n", "\n", - " function canvas_keyboard_event(event) {\n", - " return fig.key_event(event, event['data']);\n", + " function on_keyboard_event_closure(name) {\n", + " return function (event) {\n", + " return fig.key_event(event, name);\n", + " };\n", " }\n", "\n", - " canvas_div.keydown('key_press', canvas_keyboard_event);\n", - " canvas_div.keyup('key_release', canvas_keyboard_event);\n", - " this.canvas_div = canvas_div\n", - " this._canvas_extra_style(canvas_div)\n", - " this.root.append(canvas_div);\n", + " canvas_div.addEventListener(\n", + " 'keydown',\n", + " on_keyboard_event_closure('key_press')\n", + " );\n", + " canvas_div.addEventListener(\n", + " 'keyup',\n", + " on_keyboard_event_closure('key_release')\n", + " );\n", + "\n", + " this._canvas_extra_style(canvas_div);\n", + " this.root.appendChild(canvas_div);\n", "\n", - " var canvas = $('');\n", - " canvas.addClass('mpl-canvas');\n", - " canvas.attr('style', \"left: 0; top: 0; z-index: 0; outline: 0\")\n", + " var canvas = (this.canvas = document.createElement('canvas'));\n", + " canvas.classList.add('mpl-canvas');\n", + " canvas.setAttribute('style', 'box-sizing: content-box;');\n", "\n", - " this.canvas = canvas[0];\n", - " this.context = canvas[0].getContext(\"2d\");\n", + " this.context = canvas.getContext('2d');\n", "\n", - " var backingStore = this.context.backingStorePixelRatio ||\n", - "\tthis.context.webkitBackingStorePixelRatio ||\n", - "\tthis.context.mozBackingStorePixelRatio ||\n", - "\tthis.context.msBackingStorePixelRatio ||\n", - "\tthis.context.oBackingStorePixelRatio ||\n", - "\tthis.context.backingStorePixelRatio || 1;\n", + " var backingStore =\n", + " this.context.backingStorePixelRatio ||\n", + " this.context.webkitBackingStorePixelRatio ||\n", + " this.context.mozBackingStorePixelRatio ||\n", + " this.context.msBackingStorePixelRatio ||\n", + " this.context.oBackingStorePixelRatio ||\n", + " this.context.backingStorePixelRatio ||\n", + " 1;\n", "\n", - " mpl.ratio = (window.devicePixelRatio || 1) / backingStore;\n", + " this.ratio = (window.devicePixelRatio || 1) / backingStore;\n", "\n", - " var rubberband = $('');\n", - " rubberband.attr('style', \"position: absolute; left: 0; top: 0; z-index: 1;\")\n", + " var rubberband_canvas = (this.rubberband_canvas = document.createElement(\n", + " 'canvas'\n", + " ));\n", + " rubberband_canvas.setAttribute(\n", + " 'style',\n", + " 'box-sizing: content-box; position: absolute; left: 0; top: 0; z-index: 1;'\n", + " );\n", + "\n", + " // Apply a ponyfill if ResizeObserver is not implemented by browser.\n", + " if (this.ResizeObserver === undefined) {\n", + " if (window.ResizeObserver !== undefined) {\n", + " this.ResizeObserver = window.ResizeObserver;\n", + " } else {\n", + " var obs = _JSXTOOLS_RESIZE_OBSERVER({});\n", + " this.ResizeObserver = obs.ResizeObserver;\n", + " }\n", + " }\n", "\n", - " var pass_mouse_events = true;\n", + " this.resizeObserverInstance = new this.ResizeObserver(function (entries) {\n", + " var nentries = entries.length;\n", + " for (var i = 0; i < nentries; i++) {\n", + " var entry = entries[i];\n", + " var width, height;\n", + " if (entry.contentBoxSize) {\n", + " if (entry.contentBoxSize instanceof Array) {\n", + " // Chrome 84 implements new version of spec.\n", + " width = entry.contentBoxSize[0].inlineSize;\n", + " height = entry.contentBoxSize[0].blockSize;\n", + " } else {\n", + " // Firefox implements old version of spec.\n", + " width = entry.contentBoxSize.inlineSize;\n", + " height = entry.contentBoxSize.blockSize;\n", + " }\n", + " } else {\n", + " // Chrome <84 implements even older version of spec.\n", + " width = entry.contentRect.width;\n", + " height = entry.contentRect.height;\n", + " }\n", "\n", - " canvas_div.resizable({\n", - " start: function(event, ui) {\n", - " pass_mouse_events = false;\n", - " },\n", - " resize: function(event, ui) {\n", - " fig.request_resize(ui.size.width, ui.size.height);\n", - " },\n", - " stop: function(event, ui) {\n", - " pass_mouse_events = true;\n", - " fig.request_resize(ui.size.width, ui.size.height);\n", - " },\n", + " // Keep the size of the canvas and rubber band canvas in sync with\n", + " // the canvas container.\n", + " if (entry.devicePixelContentBoxSize) {\n", + " // Chrome 84 implements new version of spec.\n", + " canvas.setAttribute(\n", + " 'width',\n", + " entry.devicePixelContentBoxSize[0].inlineSize\n", + " );\n", + " canvas.setAttribute(\n", + " 'height',\n", + " entry.devicePixelContentBoxSize[0].blockSize\n", + " );\n", + " } else {\n", + " canvas.setAttribute('width', width * fig.ratio);\n", + " canvas.setAttribute('height', height * fig.ratio);\n", + " }\n", + " canvas.setAttribute(\n", + " 'style',\n", + " 'width: ' + width + 'px; height: ' + height + 'px;'\n", + " );\n", + "\n", + " rubberband_canvas.setAttribute('width', width);\n", + " rubberband_canvas.setAttribute('height', height);\n", + "\n", + " // And update the size in Python. We ignore the initial 0/0 size\n", + " // that occurs as the element is placed into the DOM, which should\n", + " // otherwise not happen due to the minimum size styling.\n", + " if (fig.ws.readyState == 1 && width != 0 && height != 0) {\n", + " fig.request_resize(width, height);\n", + " }\n", + " }\n", " });\n", + " this.resizeObserverInstance.observe(canvas_div);\n", "\n", - " function mouse_event_fn(event) {\n", - " if (pass_mouse_events)\n", - " return fig.mouse_event(event, event['data']);\n", + " function on_mouse_event_closure(name) {\n", + " return function (event) {\n", + " return fig.mouse_event(event, name);\n", + " };\n", " }\n", "\n", - " rubberband.mousedown('button_press', mouse_event_fn);\n", - " rubberband.mouseup('button_release', mouse_event_fn);\n", + " rubberband_canvas.addEventListener(\n", + " 'mousedown',\n", + " on_mouse_event_closure('button_press')\n", + " );\n", + " rubberband_canvas.addEventListener(\n", + " 'mouseup',\n", + " on_mouse_event_closure('button_release')\n", + " );\n", + " rubberband_canvas.addEventListener(\n", + " 'dblclick',\n", + " on_mouse_event_closure('dblclick')\n", + " );\n", " // Throttle sequential mouse events to 1 every 20ms.\n", - " rubberband.mousemove('motion_notify', mouse_event_fn);\n", + " rubberband_canvas.addEventListener(\n", + " 'mousemove',\n", + " on_mouse_event_closure('motion_notify')\n", + " );\n", "\n", - " rubberband.mouseenter('figure_enter', mouse_event_fn);\n", - " rubberband.mouseleave('figure_leave', mouse_event_fn);\n", + " rubberband_canvas.addEventListener(\n", + " 'mouseenter',\n", + " on_mouse_event_closure('figure_enter')\n", + " );\n", + " rubberband_canvas.addEventListener(\n", + " 'mouseleave',\n", + " on_mouse_event_closure('figure_leave')\n", + " );\n", "\n", - " canvas_div.on(\"wheel\", function (event) {\n", - " event = event.originalEvent;\n", - " event['data'] = 'scroll'\n", + " canvas_div.addEventListener('wheel', function (event) {\n", " if (event.deltaY < 0) {\n", " event.step = 1;\n", " } else {\n", " event.step = -1;\n", " }\n", - " mouse_event_fn(event);\n", + " on_mouse_event_closure('scroll')(event);\n", " });\n", "\n", - " canvas_div.append(canvas);\n", - " canvas_div.append(rubberband);\n", - "\n", - " this.rubberband = rubberband;\n", - " this.rubberband_canvas = rubberband[0];\n", - " this.rubberband_context = rubberband[0].getContext(\"2d\");\n", - " this.rubberband_context.strokeStyle = \"#000000\";\n", - "\n", - " this._resize_canvas = function(width, height) {\n", - " // Keep the size of the canvas, canvas container, and rubber band\n", - " // canvas in synch.\n", - " canvas_div.css('width', width)\n", - " canvas_div.css('height', height)\n", - "\n", - " canvas.attr('width', width * mpl.ratio);\n", - " canvas.attr('height', height * mpl.ratio);\n", - " canvas.attr('style', 'width: ' + width + 'px; height: ' + height + 'px;');\n", + " canvas_div.appendChild(canvas);\n", + " canvas_div.appendChild(rubberband_canvas);\n", "\n", - " rubberband.attr('width', width);\n", - " rubberband.attr('height', height);\n", - " }\n", + " this.rubberband_context = rubberband_canvas.getContext('2d');\n", + " this.rubberband_context.strokeStyle = '#000000';\n", "\n", - " // Set the figure to an initial 600x600px, this will subsequently be updated\n", - " // upon first draw.\n", - " this._resize_canvas(600, 600);\n", + " this._resize_canvas = function (width, height, forward) {\n", + " if (forward) {\n", + " canvas_div.style.width = width + 'px';\n", + " canvas_div.style.height = height + 'px';\n", + " }\n", + " };\n", "\n", " // Disable right mouse context menu.\n", - " $(this.rubberband_canvas).bind(\"contextmenu\",function(e){\n", + " this.rubberband_canvas.addEventListener('contextmenu', function (_e) {\n", + " event.preventDefault();\n", " return false;\n", " });\n", "\n", - " function set_focus () {\n", + " function set_focus() {\n", " canvas.focus();\n", " canvas_div.focus();\n", " }\n", "\n", " window.setTimeout(set_focus, 100);\n", - "}\n", + "};\n", "\n", - "mpl.figure.prototype._init_toolbar = function() {\n", + "mpl.figure.prototype._init_toolbar = function () {\n", " var fig = this;\n", "\n", - " var nav_element = $('
')\n", - " nav_element.attr('style', 'width: 100%');\n", - " this.root.append(nav_element);\n", + " var toolbar = document.createElement('div');\n", + " toolbar.classList = 'mpl-toolbar';\n", + " this.root.appendChild(toolbar);\n", "\n", - " // Define a callback function for later on.\n", - " function toolbar_event(event) {\n", - " return fig.toolbar_button_onclick(event['data']);\n", + " function on_click_closure(name) {\n", + " return function (_event) {\n", + " return fig.toolbar_button_onclick(name);\n", + " };\n", " }\n", - " function toolbar_mouse_event(event) {\n", - " return fig.toolbar_button_onmouseover(event['data']);\n", + "\n", + " function on_mouseover_closure(tooltip) {\n", + " return function (event) {\n", + " if (!event.currentTarget.disabled) {\n", + " return fig.toolbar_button_onmouseover(tooltip);\n", + " }\n", + " };\n", " }\n", "\n", - " for(var toolbar_ind in mpl.toolbar_items) {\n", + " fig.buttons = {};\n", + " var buttonGroup = document.createElement('div');\n", + " buttonGroup.classList = 'mpl-button-group';\n", + " for (var toolbar_ind in mpl.toolbar_items) {\n", " var name = mpl.toolbar_items[toolbar_ind][0];\n", " var tooltip = mpl.toolbar_items[toolbar_ind][1];\n", " var image = mpl.toolbar_items[toolbar_ind][2];\n", " var method_name = mpl.toolbar_items[toolbar_ind][3];\n", "\n", " if (!name) {\n", - " // put a spacer in here.\n", + " /* Instead of a spacer, we start a new button group. */\n", + " if (buttonGroup.hasChildNodes()) {\n", + " toolbar.appendChild(buttonGroup);\n", + " }\n", + " buttonGroup = document.createElement('div');\n", + " buttonGroup.classList = 'mpl-button-group';\n", " continue;\n", " }\n", - " var button = $('');\n", - " button.click(method_name, toolbar_event);\n", - " button.mouseover(tooltip, toolbar_mouse_event);\n", - " nav_element.append(button);\n", + " if (buttonGroup.hasChildNodes()) {\n", + " toolbar.appendChild(buttonGroup);\n", " }\n", "\n", " // Add the status bar.\n", - " var status_bar = $('');\n", - " nav_element.append(status_bar);\n", - " this.message = status_bar[0];\n", + " var status_bar = document.createElement('span');\n", + " status_bar.classList = 'mpl-message pull-right';\n", + " toolbar.appendChild(status_bar);\n", + " this.message = status_bar;\n", "\n", " // Add the close button to the window.\n", - " var buttongrp = $('
');\n", - " var button = $('');\n", - " button.click(function (evt) { fig.handle_close(fig, {}); } );\n", - " button.mouseover('Stop Interaction', toolbar_mouse_event);\n", - " buttongrp.append(button);\n", - " var titlebar = this.root.find($('.ui-dialog-titlebar'));\n", - " titlebar.prepend(buttongrp);\n", - "}\n", - "\n", - "mpl.figure.prototype._root_extra_style = function(el){\n", - " var fig = this\n", - " el.on(\"remove\", function(){\n", - "\tfig.close_ws(fig, {});\n", + " var buttongrp = document.createElement('div');\n", + " buttongrp.classList = 'btn-group inline pull-right';\n", + " button = document.createElement('button');\n", + " button.classList = 'btn btn-mini btn-primary';\n", + " button.href = '#';\n", + " button.title = 'Stop Interaction';\n", + " button.innerHTML = '';\n", + " button.addEventListener('click', function (_evt) {\n", + " fig.handle_close(fig, {});\n", " });\n", - "}\n", + " button.addEventListener(\n", + " 'mouseover',\n", + " on_mouseover_closure('Stop Interaction')\n", + " );\n", + " buttongrp.appendChild(button);\n", + " var titlebar = this.root.querySelector('.ui-dialog-titlebar');\n", + " titlebar.insertBefore(buttongrp, titlebar.firstChild);\n", + "};\n", + "\n", + "mpl.figure.prototype._remove_fig_handler = function (event) {\n", + " var fig = event.data.fig;\n", + " if (event.target !== this) {\n", + " // Ignore bubbled events from children.\n", + " return;\n", + " }\n", + " fig.close_ws(fig, {});\n", + "};\n", + "\n", + "mpl.figure.prototype._root_extra_style = function (el) {\n", + " el.style.boxSizing = 'content-box'; // override notebook setting of border-box.\n", + "};\n", "\n", - "mpl.figure.prototype._canvas_extra_style = function(el){\n", + "mpl.figure.prototype._canvas_extra_style = function (el) {\n", " // this is important to make the div 'focusable\n", - " el.attr('tabindex', 0)\n", + " el.setAttribute('tabindex', 0);\n", " // reach out to IPython and tell the keyboard manager to turn it's self\n", " // off when our div gets focus\n", "\n", " // location in version 3\n", " if (IPython.notebook.keyboard_manager) {\n", " IPython.notebook.keyboard_manager.register_events(el);\n", - " }\n", - " else {\n", + " } else {\n", " // location in version 2\n", " IPython.keyboard_manager.register_events(el);\n", " }\n", + "};\n", "\n", - "}\n", - "\n", - "mpl.figure.prototype._key_event_extra = function(event, name) {\n", - " var manager = IPython.notebook.keyboard_manager;\n", - " if (!manager)\n", - " manager = IPython.keyboard_manager;\n", - "\n", + "mpl.figure.prototype._key_event_extra = function (event, _name) {\n", " // Check for shift+enter\n", - " if (event.shiftKey && event.which == 13) {\n", + " if (event.shiftKey && event.which === 13) {\n", " this.canvas_div.blur();\n", - " event.shiftKey = false;\n", - " // Send a \"J\" for go to next cell\n", - " event.which = 74;\n", - " event.keyCode = 74;\n", - " manager.command_mode();\n", - " manager.handle_keydown(event);\n", + " // select the cell after this one\n", + " var index = IPython.notebook.find_cell_index(this.cell_info[0]);\n", + " IPython.notebook.select(index + 1);\n", " }\n", - "}\n", + "};\n", "\n", - "mpl.figure.prototype.handle_save = function(fig, msg) {\n", + "mpl.figure.prototype.handle_save = function (fig, _msg) {\n", " fig.ondownload(fig, null);\n", - "}\n", - "\n", + "};\n", "\n", - "mpl.find_output_cell = function(html_output) {\n", + "mpl.find_output_cell = function (html_output) {\n", " // Return the cell and output element which can be found *uniquely* in the notebook.\n", " // Note - this is a bit hacky, but it is done because the \"notebook_saving.Notebook\"\n", " // IPython event is triggered only after the cells have been serialised, which for\n", " // our purposes (turning an active figure into a static one), is too late.\n", " var cells = IPython.notebook.get_cells();\n", " var ncells = cells.length;\n", - " for (var i=0; i= 3 moved mimebundle to data attribute of output\n", " data = data.data;\n", " }\n", - " if (data['text/html'] == html_output) {\n", + " if (data['text/html'] === html_output) {\n", " return [cell, data, j];\n", " }\n", " }\n", " }\n", " }\n", - "}\n", + "};\n", "\n", "// Register the function which deals with the matplotlib target/channel.\n", "// The kernel may be null if the page has been refreshed.\n", - "if (IPython.notebook.kernel != null) {\n", - " IPython.notebook.kernel.comm_manager.register_target('matplotlib', mpl.mpl_figure_comm);\n", + "if (IPython.notebook.kernel !== null) {\n", + " IPython.notebook.kernel.comm_manager.register_target(\n", + " 'matplotlib',\n", + " mpl.mpl_figure_comm\n", + " );\n", "}\n" ], "text/plain": [ @@ -1898,7 +2237,7 @@ { "data": { "text/html": [ - "" + "" ], "text/plain": [ "" @@ -1922,43 +2261,45 @@ }, { "cell_type": "code", - "execution_count": 8, + "execution_count": 9, "metadata": {}, "outputs": [ { "data": { "application/javascript": [ "/* Put everything inside the global mpl namespace */\n", + "/* global mpl */\n", "window.mpl = {};\n", "\n", - "\n", - "mpl.get_websocket_type = function() {\n", - " if (typeof(WebSocket) !== 'undefined') {\n", + "mpl.get_websocket_type = function () {\n", + " if (typeof WebSocket !== 'undefined') {\n", " return WebSocket;\n", - " } else if (typeof(MozWebSocket) !== 'undefined') {\n", + " } else if (typeof MozWebSocket !== 'undefined') {\n", " return MozWebSocket;\n", " } else {\n", - " alert('Your browser does not have WebSocket support.' +\n", - " 'Please try Chrome, Safari or Firefox ≥ 6. ' +\n", - " 'Firefox 4 and 5 are also supported but you ' +\n", - " 'have to enable WebSockets in about:config.');\n", - " };\n", - "}\n", + " alert(\n", + " 'Your browser does not have WebSocket support. ' +\n", + " 'Please try Chrome, Safari or Firefox ≥ 6. ' +\n", + " 'Firefox 4 and 5 are also supported but you ' +\n", + " 'have to enable WebSockets in about:config.'\n", + " );\n", + " }\n", + "};\n", "\n", - "mpl.figure = function(figure_id, websocket, ondownload, parent_element) {\n", + "mpl.figure = function (figure_id, websocket, ondownload, parent_element) {\n", " this.id = figure_id;\n", "\n", " this.ws = websocket;\n", "\n", - " this.supports_binary = (this.ws.binaryType != undefined);\n", + " this.supports_binary = this.ws.binaryType !== undefined;\n", "\n", " if (!this.supports_binary) {\n", - " var warnings = document.getElementById(\"mpl-warnings\");\n", + " var warnings = document.getElementById('mpl-warnings');\n", " if (warnings) {\n", " warnings.style.display = 'block';\n", - " warnings.textContent = (\n", - " \"This browser does not support binary websocket messages. \" +\n", - " \"Performance may be slow.\");\n", + " warnings.textContent =\n", + " 'This browser does not support binary websocket messages. ' +\n", + " 'Performance may be slow.';\n", " }\n", " }\n", "\n", @@ -1973,11 +2314,11 @@ "\n", " this.image_mode = 'full';\n", "\n", - " this.root = $('
');\n", - " this._root_extra_style(this.root)\n", - " this.root.attr('style', 'display: inline-block');\n", + " this.root = document.createElement('div');\n", + " this.root.setAttribute('style', 'display: inline-block');\n", + " this._root_extra_style(this.root);\n", "\n", - " $(parent_element).append(this.root);\n", + " parent_element.appendChild(this.root);\n", "\n", " this._init_header(this);\n", " this._init_canvas(this);\n", @@ -1987,285 +2328,366 @@ "\n", " this.waiting = false;\n", "\n", - " this.ws.onopen = function () {\n", - " fig.send_message(\"supports_binary\", {value: fig.supports_binary});\n", - " fig.send_message(\"send_image_mode\", {});\n", - " if (mpl.ratio != 1) {\n", - " fig.send_message(\"set_dpi_ratio\", {'dpi_ratio': mpl.ratio});\n", - " }\n", - " fig.send_message(\"refresh\", {});\n", + " this.ws.onopen = function () {\n", + " fig.send_message('supports_binary', { value: fig.supports_binary });\n", + " fig.send_message('send_image_mode', {});\n", + " if (fig.ratio !== 1) {\n", + " fig.send_message('set_device_pixel_ratio', {\n", + " device_pixel_ratio: fig.ratio,\n", + " });\n", " }\n", + " fig.send_message('refresh', {});\n", + " };\n", "\n", - " this.imageObj.onload = function() {\n", - " if (fig.image_mode == 'full') {\n", - " // Full images could contain transparency (where diff images\n", - " // almost always do), so we need to clear the canvas so that\n", - " // there is no ghosting.\n", - " fig.context.clearRect(0, 0, fig.canvas.width, fig.canvas.height);\n", - " }\n", - " fig.context.drawImage(fig.imageObj, 0, 0);\n", - " };\n", + " this.imageObj.onload = function () {\n", + " if (fig.image_mode === 'full') {\n", + " // Full images could contain transparency (where diff images\n", + " // almost always do), so we need to clear the canvas so that\n", + " // there is no ghosting.\n", + " fig.context.clearRect(0, 0, fig.canvas.width, fig.canvas.height);\n", + " }\n", + " fig.context.drawImage(fig.imageObj, 0, 0);\n", + " };\n", "\n", - " this.imageObj.onunload = function() {\n", + " this.imageObj.onunload = function () {\n", " fig.ws.close();\n", - " }\n", + " };\n", "\n", " this.ws.onmessage = this._make_on_message_function(this);\n", "\n", " this.ondownload = ondownload;\n", - "}\n", - "\n", - "mpl.figure.prototype._init_header = function() {\n", - " var titlebar = $(\n", - " '
');\n", - " var titletext = $(\n", - " '
');\n", - " titlebar.append(titletext)\n", - " this.root.append(titlebar);\n", - " this.header = titletext[0];\n", - "}\n", - "\n", - "\n", - "\n", - "mpl.figure.prototype._canvas_extra_style = function(canvas_div) {\n", - "\n", - "}\n", + "};\n", "\n", + "mpl.figure.prototype._init_header = function () {\n", + " var titlebar = document.createElement('div');\n", + " titlebar.classList =\n", + " 'ui-dialog-titlebar ui-widget-header ui-corner-all ui-helper-clearfix';\n", + " var titletext = document.createElement('div');\n", + " titletext.classList = 'ui-dialog-title';\n", + " titletext.setAttribute(\n", + " 'style',\n", + " 'width: 100%; text-align: center; padding: 3px;'\n", + " );\n", + " titlebar.appendChild(titletext);\n", + " this.root.appendChild(titlebar);\n", + " this.header = titletext;\n", + "};\n", "\n", - "mpl.figure.prototype._root_extra_style = function(canvas_div) {\n", + "mpl.figure.prototype._canvas_extra_style = function (_canvas_div) {};\n", "\n", - "}\n", + "mpl.figure.prototype._root_extra_style = function (_canvas_div) {};\n", "\n", - "mpl.figure.prototype._init_canvas = function() {\n", + "mpl.figure.prototype._init_canvas = function () {\n", " var fig = this;\n", "\n", - " var canvas_div = $('
');\n", - "\n", - " canvas_div.attr('style', 'position: relative; clear: both; outline: 0');\n", + " var canvas_div = (this.canvas_div = document.createElement('div'));\n", + " canvas_div.setAttribute(\n", + " 'style',\n", + " 'border: 1px solid #ddd;' +\n", + " 'box-sizing: content-box;' +\n", + " 'clear: both;' +\n", + " 'min-height: 1px;' +\n", + " 'min-width: 1px;' +\n", + " 'outline: 0;' +\n", + " 'overflow: hidden;' +\n", + " 'position: relative;' +\n", + " 'resize: both;'\n", + " );\n", "\n", - " function canvas_keyboard_event(event) {\n", - " return fig.key_event(event, event['data']);\n", + " function on_keyboard_event_closure(name) {\n", + " return function (event) {\n", + " return fig.key_event(event, name);\n", + " };\n", " }\n", "\n", - " canvas_div.keydown('key_press', canvas_keyboard_event);\n", - " canvas_div.keyup('key_release', canvas_keyboard_event);\n", - " this.canvas_div = canvas_div\n", - " this._canvas_extra_style(canvas_div)\n", - " this.root.append(canvas_div);\n", + " canvas_div.addEventListener(\n", + " 'keydown',\n", + " on_keyboard_event_closure('key_press')\n", + " );\n", + " canvas_div.addEventListener(\n", + " 'keyup',\n", + " on_keyboard_event_closure('key_release')\n", + " );\n", + "\n", + " this._canvas_extra_style(canvas_div);\n", + " this.root.appendChild(canvas_div);\n", + "\n", + " var canvas = (this.canvas = document.createElement('canvas'));\n", + " canvas.classList.add('mpl-canvas');\n", + " canvas.setAttribute('style', 'box-sizing: content-box;');\n", "\n", - " var canvas = $('');\n", - " canvas.addClass('mpl-canvas');\n", - " canvas.attr('style', \"left: 0; top: 0; z-index: 0; outline: 0\")\n", + " this.context = canvas.getContext('2d');\n", "\n", - " this.canvas = canvas[0];\n", - " this.context = canvas[0].getContext(\"2d\");\n", + " var backingStore =\n", + " this.context.backingStorePixelRatio ||\n", + " this.context.webkitBackingStorePixelRatio ||\n", + " this.context.mozBackingStorePixelRatio ||\n", + " this.context.msBackingStorePixelRatio ||\n", + " this.context.oBackingStorePixelRatio ||\n", + " this.context.backingStorePixelRatio ||\n", + " 1;\n", "\n", - " var backingStore = this.context.backingStorePixelRatio ||\n", - "\tthis.context.webkitBackingStorePixelRatio ||\n", - "\tthis.context.mozBackingStorePixelRatio ||\n", - "\tthis.context.msBackingStorePixelRatio ||\n", - "\tthis.context.oBackingStorePixelRatio ||\n", - "\tthis.context.backingStorePixelRatio || 1;\n", + " this.ratio = (window.devicePixelRatio || 1) / backingStore;\n", "\n", - " mpl.ratio = (window.devicePixelRatio || 1) / backingStore;\n", + " var rubberband_canvas = (this.rubberband_canvas = document.createElement(\n", + " 'canvas'\n", + " ));\n", + " rubberband_canvas.setAttribute(\n", + " 'style',\n", + " 'box-sizing: content-box; position: absolute; left: 0; top: 0; z-index: 1;'\n", + " );\n", "\n", - " var rubberband = $('');\n", - " rubberband.attr('style', \"position: absolute; left: 0; top: 0; z-index: 1;\")\n", + " // Apply a ponyfill if ResizeObserver is not implemented by browser.\n", + " if (this.ResizeObserver === undefined) {\n", + " if (window.ResizeObserver !== undefined) {\n", + " this.ResizeObserver = window.ResizeObserver;\n", + " } else {\n", + " var obs = _JSXTOOLS_RESIZE_OBSERVER({});\n", + " this.ResizeObserver = obs.ResizeObserver;\n", + " }\n", + " }\n", "\n", - " var pass_mouse_events = true;\n", + " this.resizeObserverInstance = new this.ResizeObserver(function (entries) {\n", + " var nentries = entries.length;\n", + " for (var i = 0; i < nentries; i++) {\n", + " var entry = entries[i];\n", + " var width, height;\n", + " if (entry.contentBoxSize) {\n", + " if (entry.contentBoxSize instanceof Array) {\n", + " // Chrome 84 implements new version of spec.\n", + " width = entry.contentBoxSize[0].inlineSize;\n", + " height = entry.contentBoxSize[0].blockSize;\n", + " } else {\n", + " // Firefox implements old version of spec.\n", + " width = entry.contentBoxSize.inlineSize;\n", + " height = entry.contentBoxSize.blockSize;\n", + " }\n", + " } else {\n", + " // Chrome <84 implements even older version of spec.\n", + " width = entry.contentRect.width;\n", + " height = entry.contentRect.height;\n", + " }\n", "\n", - " canvas_div.resizable({\n", - " start: function(event, ui) {\n", - " pass_mouse_events = false;\n", - " },\n", - " resize: function(event, ui) {\n", - " fig.request_resize(ui.size.width, ui.size.height);\n", - " },\n", - " stop: function(event, ui) {\n", - " pass_mouse_events = true;\n", - " fig.request_resize(ui.size.width, ui.size.height);\n", - " },\n", + " // Keep the size of the canvas and rubber band canvas in sync with\n", + " // the canvas container.\n", + " if (entry.devicePixelContentBoxSize) {\n", + " // Chrome 84 implements new version of spec.\n", + " canvas.setAttribute(\n", + " 'width',\n", + " entry.devicePixelContentBoxSize[0].inlineSize\n", + " );\n", + " canvas.setAttribute(\n", + " 'height',\n", + " entry.devicePixelContentBoxSize[0].blockSize\n", + " );\n", + " } else {\n", + " canvas.setAttribute('width', width * fig.ratio);\n", + " canvas.setAttribute('height', height * fig.ratio);\n", + " }\n", + " canvas.setAttribute(\n", + " 'style',\n", + " 'width: ' + width + 'px; height: ' + height + 'px;'\n", + " );\n", + "\n", + " rubberband_canvas.setAttribute('width', width);\n", + " rubberband_canvas.setAttribute('height', height);\n", + "\n", + " // And update the size in Python. We ignore the initial 0/0 size\n", + " // that occurs as the element is placed into the DOM, which should\n", + " // otherwise not happen due to the minimum size styling.\n", + " if (fig.ws.readyState == 1 && width != 0 && height != 0) {\n", + " fig.request_resize(width, height);\n", + " }\n", + " }\n", " });\n", + " this.resizeObserverInstance.observe(canvas_div);\n", "\n", - " function mouse_event_fn(event) {\n", - " if (pass_mouse_events)\n", - " return fig.mouse_event(event, event['data']);\n", + " function on_mouse_event_closure(name) {\n", + " return function (event) {\n", + " return fig.mouse_event(event, name);\n", + " };\n", " }\n", "\n", - " rubberband.mousedown('button_press', mouse_event_fn);\n", - " rubberband.mouseup('button_release', mouse_event_fn);\n", + " rubberband_canvas.addEventListener(\n", + " 'mousedown',\n", + " on_mouse_event_closure('button_press')\n", + " );\n", + " rubberband_canvas.addEventListener(\n", + " 'mouseup',\n", + " on_mouse_event_closure('button_release')\n", + " );\n", + " rubberband_canvas.addEventListener(\n", + " 'dblclick',\n", + " on_mouse_event_closure('dblclick')\n", + " );\n", " // Throttle sequential mouse events to 1 every 20ms.\n", - " rubberband.mousemove('motion_notify', mouse_event_fn);\n", + " rubberband_canvas.addEventListener(\n", + " 'mousemove',\n", + " on_mouse_event_closure('motion_notify')\n", + " );\n", "\n", - " rubberband.mouseenter('figure_enter', mouse_event_fn);\n", - " rubberband.mouseleave('figure_leave', mouse_event_fn);\n", + " rubberband_canvas.addEventListener(\n", + " 'mouseenter',\n", + " on_mouse_event_closure('figure_enter')\n", + " );\n", + " rubberband_canvas.addEventListener(\n", + " 'mouseleave',\n", + " on_mouse_event_closure('figure_leave')\n", + " );\n", "\n", - " canvas_div.on(\"wheel\", function (event) {\n", - " event = event.originalEvent;\n", - " event['data'] = 'scroll'\n", + " canvas_div.addEventListener('wheel', function (event) {\n", " if (event.deltaY < 0) {\n", " event.step = 1;\n", " } else {\n", " event.step = -1;\n", " }\n", - " mouse_event_fn(event);\n", + " on_mouse_event_closure('scroll')(event);\n", " });\n", "\n", - " canvas_div.append(canvas);\n", - " canvas_div.append(rubberband);\n", + " canvas_div.appendChild(canvas);\n", + " canvas_div.appendChild(rubberband_canvas);\n", "\n", - " this.rubberband = rubberband;\n", - " this.rubberband_canvas = rubberband[0];\n", - " this.rubberband_context = rubberband[0].getContext(\"2d\");\n", - " this.rubberband_context.strokeStyle = \"#000000\";\n", - "\n", - " this._resize_canvas = function(width, height) {\n", - " // Keep the size of the canvas, canvas container, and rubber band\n", - " // canvas in synch.\n", - " canvas_div.css('width', width)\n", - " canvas_div.css('height', height)\n", - "\n", - " canvas.attr('width', width * mpl.ratio);\n", - " canvas.attr('height', height * mpl.ratio);\n", - " canvas.attr('style', 'width: ' + width + 'px; height: ' + height + 'px;');\n", - "\n", - " rubberband.attr('width', width);\n", - " rubberband.attr('height', height);\n", - " }\n", + " this.rubberband_context = rubberband_canvas.getContext('2d');\n", + " this.rubberband_context.strokeStyle = '#000000';\n", "\n", - " // Set the figure to an initial 600x600px, this will subsequently be updated\n", - " // upon first draw.\n", - " this._resize_canvas(600, 600);\n", + " this._resize_canvas = function (width, height, forward) {\n", + " if (forward) {\n", + " canvas_div.style.width = width + 'px';\n", + " canvas_div.style.height = height + 'px';\n", + " }\n", + " };\n", "\n", " // Disable right mouse context menu.\n", - " $(this.rubberband_canvas).bind(\"contextmenu\",function(e){\n", + " this.rubberband_canvas.addEventListener('contextmenu', function (_e) {\n", + " event.preventDefault();\n", " return false;\n", " });\n", "\n", - " function set_focus () {\n", + " function set_focus() {\n", " canvas.focus();\n", " canvas_div.focus();\n", " }\n", "\n", " window.setTimeout(set_focus, 100);\n", - "}\n", + "};\n", "\n", - "mpl.figure.prototype._init_toolbar = function() {\n", + "mpl.figure.prototype._init_toolbar = function () {\n", " var fig = this;\n", "\n", - " var nav_element = $('
')\n", - " nav_element.attr('style', 'width: 100%');\n", - " this.root.append(nav_element);\n", + " var toolbar = document.createElement('div');\n", + " toolbar.classList = 'mpl-toolbar';\n", + " this.root.appendChild(toolbar);\n", "\n", - " // Define a callback function for later on.\n", - " function toolbar_event(event) {\n", - " return fig.toolbar_button_onclick(event['data']);\n", + " function on_click_closure(name) {\n", + " return function (_event) {\n", + " return fig.toolbar_button_onclick(name);\n", + " };\n", " }\n", - " function toolbar_mouse_event(event) {\n", - " return fig.toolbar_button_onmouseover(event['data']);\n", + "\n", + " function on_mouseover_closure(tooltip) {\n", + " return function (event) {\n", + " if (!event.currentTarget.disabled) {\n", + " return fig.toolbar_button_onmouseover(tooltip);\n", + " }\n", + " };\n", " }\n", "\n", - " for(var toolbar_ind in mpl.toolbar_items) {\n", + " fig.buttons = {};\n", + " var buttonGroup = document.createElement('div');\n", + " buttonGroup.classList = 'mpl-button-group';\n", + " for (var toolbar_ind in mpl.toolbar_items) {\n", " var name = mpl.toolbar_items[toolbar_ind][0];\n", " var tooltip = mpl.toolbar_items[toolbar_ind][1];\n", " var image = mpl.toolbar_items[toolbar_ind][2];\n", " var method_name = mpl.toolbar_items[toolbar_ind][3];\n", "\n", " if (!name) {\n", - " // put a spacer in here.\n", + " /* Instead of a spacer, we start a new button group. */\n", + " if (buttonGroup.hasChildNodes()) {\n", + " toolbar.appendChild(buttonGroup);\n", + " }\n", + " buttonGroup = document.createElement('div');\n", + " buttonGroup.classList = 'mpl-button-group';\n", " continue;\n", " }\n", - " var button = $('');\n", - " button.click(method_name, toolbar_event);\n", - " button.mouseover(tooltip, toolbar_mouse_event);\n", - " nav_element.append(button);\n", + " if (buttonGroup.hasChildNodes()) {\n", + " toolbar.appendChild(buttonGroup);\n", " }\n", "\n", " // Add the status bar.\n", - " var status_bar = $('');\n", - " nav_element.append(status_bar);\n", - " this.message = status_bar[0];\n", + " var status_bar = document.createElement('span');\n", + " status_bar.classList = 'mpl-message pull-right';\n", + " toolbar.appendChild(status_bar);\n", + " this.message = status_bar;\n", "\n", " // Add the close button to the window.\n", - " var buttongrp = $('
');\n", - " var button = $('');\n", - " button.click(function (evt) { fig.handle_close(fig, {}); } );\n", - " button.mouseover('Stop Interaction', toolbar_mouse_event);\n", - " buttongrp.append(button);\n", - " var titlebar = this.root.find($('.ui-dialog-titlebar'));\n", - " titlebar.prepend(buttongrp);\n", - "}\n", - "\n", - "mpl.figure.prototype._root_extra_style = function(el){\n", - " var fig = this\n", - " el.on(\"remove\", function(){\n", - "\tfig.close_ws(fig, {});\n", + " var buttongrp = document.createElement('div');\n", + " buttongrp.classList = 'btn-group inline pull-right';\n", + " button = document.createElement('button');\n", + " button.classList = 'btn btn-mini btn-primary';\n", + " button.href = '#';\n", + " button.title = 'Stop Interaction';\n", + " button.innerHTML = '';\n", + " button.addEventListener('click', function (_evt) {\n", + " fig.handle_close(fig, {});\n", " });\n", - "}\n", + " button.addEventListener(\n", + " 'mouseover',\n", + " on_mouseover_closure('Stop Interaction')\n", + " );\n", + " buttongrp.appendChild(button);\n", + " var titlebar = this.root.querySelector('.ui-dialog-titlebar');\n", + " titlebar.insertBefore(buttongrp, titlebar.firstChild);\n", + "};\n", "\n", - "mpl.figure.prototype._canvas_extra_style = function(el){\n", + "mpl.figure.prototype._remove_fig_handler = function (event) {\n", + " var fig = event.data.fig;\n", + " if (event.target !== this) {\n", + " // Ignore bubbled events from children.\n", + " return;\n", + " }\n", + " fig.close_ws(fig, {});\n", + "};\n", + "\n", + "mpl.figure.prototype._root_extra_style = function (el) {\n", + " el.style.boxSizing = 'content-box'; // override notebook setting of border-box.\n", + "};\n", + "\n", + "mpl.figure.prototype._canvas_extra_style = function (el) {\n", " // this is important to make the div 'focusable\n", - " el.attr('tabindex', 0)\n", + " el.setAttribute('tabindex', 0);\n", " // reach out to IPython and tell the keyboard manager to turn it's self\n", " // off when our div gets focus\n", "\n", " // location in version 3\n", " if (IPython.notebook.keyboard_manager) {\n", " IPython.notebook.keyboard_manager.register_events(el);\n", - " }\n", - " else {\n", + " } else {\n", " // location in version 2\n", " IPython.keyboard_manager.register_events(el);\n", " }\n", + "};\n", "\n", - "}\n", - "\n", - "mpl.figure.prototype._key_event_extra = function(event, name) {\n", - " var manager = IPython.notebook.keyboard_manager;\n", - " if (!manager)\n", - " manager = IPython.keyboard_manager;\n", - "\n", + "mpl.figure.prototype._key_event_extra = function (event, _name) {\n", " // Check for shift+enter\n", - " if (event.shiftKey && event.which == 13) {\n", + " if (event.shiftKey && event.which === 13) {\n", " this.canvas_div.blur();\n", - " event.shiftKey = false;\n", - " // Send a \"J\" for go to next cell\n", - " event.which = 74;\n", - " event.keyCode = 74;\n", - " manager.command_mode();\n", - " manager.handle_keydown(event);\n", + " // select the cell after this one\n", + " var index = IPython.notebook.find_cell_index(this.cell_info[0]);\n", + " IPython.notebook.select(index + 1);\n", " }\n", - "}\n", + "};\n", "\n", - "mpl.figure.prototype.handle_save = function(fig, msg) {\n", + "mpl.figure.prototype.handle_save = function (fig, _msg) {\n", " fig.ondownload(fig, null);\n", - "}\n", + "};\n", "\n", - "\n", - "mpl.find_output_cell = function(html_output) {\n", + "mpl.find_output_cell = function (html_output) {\n", " // Return the cell and output element which can be found *uniquely* in the notebook.\n", " // Note - this is a bit hacky, but it is done because the \"notebook_saving.Notebook\"\n", " // IPython event is triggered only after the cells have been serialised, which for\n", " // our purposes (turning an active figure into a static one), is too late.\n", " var cells = IPython.notebook.get_cells();\n", " var ncells = cells.length;\n", - " for (var i=0; i= 3 moved mimebundle to data attribute of output\n", " data = data.data;\n", " }\n", - " if (data['text/html'] == html_output) {\n", + " if (data['text/html'] === html_output) {\n", " return [cell, data, j];\n", " }\n", " }\n", " }\n", " }\n", - "}\n", + "};\n", "\n", "// Register the function which deals with the matplotlib target/channel.\n", "// The kernel may be null if the page has been refreshed.\n", - "if (IPython.notebook.kernel != null) {\n", - " IPython.notebook.kernel.comm_manager.register_target('matplotlib', mpl.mpl_figure_comm);\n", + "if (IPython.notebook.kernel !== null) {\n", + " IPython.notebook.kernel.comm_manager.register_target(\n", + " 'matplotlib',\n", + " mpl.mpl_figure_comm\n", + " );\n", "}\n" ], "text/plain": [ @@ -3518,7 +4227,7 @@ { "data": { "text/html": [ - "" + "" ], "text/plain": [ "" @@ -3530,55 +4239,57 @@ ], "source": [ "fig = plt.figure()\n", - "out = ct.bode_plot(pt1_w001hzs)" + "out = ct.bode_plot(pt1_w001hzs, Hz=True)" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ - "### Bode plot with higher resolution" + "### PT1 with additional integrator, continuous and discrete" ] }, { "cell_type": "code", - "execution_count": 10, + "execution_count": 11, "metadata": {}, "outputs": [ { "data": { "application/javascript": [ "/* Put everything inside the global mpl namespace */\n", + "/* global mpl */\n", "window.mpl = {};\n", "\n", - "\n", - "mpl.get_websocket_type = function() {\n", - " if (typeof(WebSocket) !== 'undefined') {\n", + "mpl.get_websocket_type = function () {\n", + " if (typeof WebSocket !== 'undefined') {\n", " return WebSocket;\n", - " } else if (typeof(MozWebSocket) !== 'undefined') {\n", + " } else if (typeof MozWebSocket !== 'undefined') {\n", " return MozWebSocket;\n", " } else {\n", - " alert('Your browser does not have WebSocket support.' +\n", - " 'Please try Chrome, Safari or Firefox ≥ 6. ' +\n", - " 'Firefox 4 and 5 are also supported but you ' +\n", - " 'have to enable WebSockets in about:config.');\n", - " };\n", - "}\n", + " alert(\n", + " 'Your browser does not have WebSocket support. ' +\n", + " 'Please try Chrome, Safari or Firefox ≥ 6. ' +\n", + " 'Firefox 4 and 5 are also supported but you ' +\n", + " 'have to enable WebSockets in about:config.'\n", + " );\n", + " }\n", + "};\n", "\n", - "mpl.figure = function(figure_id, websocket, ondownload, parent_element) {\n", + "mpl.figure = function (figure_id, websocket, ondownload, parent_element) {\n", " this.id = figure_id;\n", "\n", " this.ws = websocket;\n", "\n", - " this.supports_binary = (this.ws.binaryType != undefined);\n", + " this.supports_binary = this.ws.binaryType !== undefined;\n", "\n", " if (!this.supports_binary) {\n", - " var warnings = document.getElementById(\"mpl-warnings\");\n", + " var warnings = document.getElementById('mpl-warnings');\n", " if (warnings) {\n", " warnings.style.display = 'block';\n", - " warnings.textContent = (\n", - " \"This browser does not support binary websocket messages. \" +\n", - " \"Performance may be slow.\");\n", + " warnings.textContent =\n", + " 'This browser does not support binary websocket messages. ' +\n", + " 'Performance may be slow.';\n", " }\n", " }\n", "\n", @@ -3593,11 +4304,11 @@ "\n", " this.image_mode = 'full';\n", "\n", - " this.root = $('
');\n", - " this._root_extra_style(this.root)\n", - " this.root.attr('style', 'display: inline-block');\n", + " this.root = document.createElement('div');\n", + " this.root.setAttribute('style', 'display: inline-block');\n", + " this._root_extra_style(this.root);\n", "\n", - " $(parent_element).append(this.root);\n", + " parent_element.appendChild(this.root);\n", "\n", " this._init_header(this);\n", " this._init_canvas(this);\n", @@ -3607,285 +4318,366 @@ "\n", " this.waiting = false;\n", "\n", - " this.ws.onopen = function () {\n", - " fig.send_message(\"supports_binary\", {value: fig.supports_binary});\n", - " fig.send_message(\"send_image_mode\", {});\n", - " if (mpl.ratio != 1) {\n", - " fig.send_message(\"set_dpi_ratio\", {'dpi_ratio': mpl.ratio});\n", - " }\n", - " fig.send_message(\"refresh\", {});\n", + " this.ws.onopen = function () {\n", + " fig.send_message('supports_binary', { value: fig.supports_binary });\n", + " fig.send_message('send_image_mode', {});\n", + " if (fig.ratio !== 1) {\n", + " fig.send_message('set_device_pixel_ratio', {\n", + " device_pixel_ratio: fig.ratio,\n", + " });\n", " }\n", + " fig.send_message('refresh', {});\n", + " };\n", "\n", - " this.imageObj.onload = function() {\n", - " if (fig.image_mode == 'full') {\n", - " // Full images could contain transparency (where diff images\n", - " // almost always do), so we need to clear the canvas so that\n", - " // there is no ghosting.\n", - " fig.context.clearRect(0, 0, fig.canvas.width, fig.canvas.height);\n", - " }\n", - " fig.context.drawImage(fig.imageObj, 0, 0);\n", - " };\n", + " this.imageObj.onload = function () {\n", + " if (fig.image_mode === 'full') {\n", + " // Full images could contain transparency (where diff images\n", + " // almost always do), so we need to clear the canvas so that\n", + " // there is no ghosting.\n", + " fig.context.clearRect(0, 0, fig.canvas.width, fig.canvas.height);\n", + " }\n", + " fig.context.drawImage(fig.imageObj, 0, 0);\n", + " };\n", "\n", - " this.imageObj.onunload = function() {\n", + " this.imageObj.onunload = function () {\n", " fig.ws.close();\n", - " }\n", + " };\n", "\n", " this.ws.onmessage = this._make_on_message_function(this);\n", "\n", " this.ondownload = ondownload;\n", - "}\n", - "\n", - "mpl.figure.prototype._init_header = function() {\n", - " var titlebar = $(\n", - " '
');\n", - " var titletext = $(\n", - " '
');\n", - " titlebar.append(titletext)\n", - " this.root.append(titlebar);\n", - " this.header = titletext[0];\n", - "}\n", - "\n", - "\n", - "\n", - "mpl.figure.prototype._canvas_extra_style = function(canvas_div) {\n", - "\n", - "}\n", + "};\n", "\n", + "mpl.figure.prototype._init_header = function () {\n", + " var titlebar = document.createElement('div');\n", + " titlebar.classList =\n", + " 'ui-dialog-titlebar ui-widget-header ui-corner-all ui-helper-clearfix';\n", + " var titletext = document.createElement('div');\n", + " titletext.classList = 'ui-dialog-title';\n", + " titletext.setAttribute(\n", + " 'style',\n", + " 'width: 100%; text-align: center; padding: 3px;'\n", + " );\n", + " titlebar.appendChild(titletext);\n", + " this.root.appendChild(titlebar);\n", + " this.header = titletext;\n", + "};\n", "\n", - "mpl.figure.prototype._root_extra_style = function(canvas_div) {\n", + "mpl.figure.prototype._canvas_extra_style = function (_canvas_div) {};\n", "\n", - "}\n", + "mpl.figure.prototype._root_extra_style = function (_canvas_div) {};\n", "\n", - "mpl.figure.prototype._init_canvas = function() {\n", + "mpl.figure.prototype._init_canvas = function () {\n", " var fig = this;\n", "\n", - " var canvas_div = $('
');\n", - "\n", - " canvas_div.attr('style', 'position: relative; clear: both; outline: 0');\n", + " var canvas_div = (this.canvas_div = document.createElement('div'));\n", + " canvas_div.setAttribute(\n", + " 'style',\n", + " 'border: 1px solid #ddd;' +\n", + " 'box-sizing: content-box;' +\n", + " 'clear: both;' +\n", + " 'min-height: 1px;' +\n", + " 'min-width: 1px;' +\n", + " 'outline: 0;' +\n", + " 'overflow: hidden;' +\n", + " 'position: relative;' +\n", + " 'resize: both;'\n", + " );\n", "\n", - " function canvas_keyboard_event(event) {\n", - " return fig.key_event(event, event['data']);\n", + " function on_keyboard_event_closure(name) {\n", + " return function (event) {\n", + " return fig.key_event(event, name);\n", + " };\n", " }\n", "\n", - " canvas_div.keydown('key_press', canvas_keyboard_event);\n", - " canvas_div.keyup('key_release', canvas_keyboard_event);\n", - " this.canvas_div = canvas_div\n", - " this._canvas_extra_style(canvas_div)\n", - " this.root.append(canvas_div);\n", + " canvas_div.addEventListener(\n", + " 'keydown',\n", + " on_keyboard_event_closure('key_press')\n", + " );\n", + " canvas_div.addEventListener(\n", + " 'keyup',\n", + " on_keyboard_event_closure('key_release')\n", + " );\n", + "\n", + " this._canvas_extra_style(canvas_div);\n", + " this.root.appendChild(canvas_div);\n", "\n", - " var canvas = $('');\n", - " canvas.addClass('mpl-canvas');\n", - " canvas.attr('style', \"left: 0; top: 0; z-index: 0; outline: 0\")\n", + " var canvas = (this.canvas = document.createElement('canvas'));\n", + " canvas.classList.add('mpl-canvas');\n", + " canvas.setAttribute('style', 'box-sizing: content-box;');\n", "\n", - " this.canvas = canvas[0];\n", - " this.context = canvas[0].getContext(\"2d\");\n", + " this.context = canvas.getContext('2d');\n", "\n", - " var backingStore = this.context.backingStorePixelRatio ||\n", - "\tthis.context.webkitBackingStorePixelRatio ||\n", - "\tthis.context.mozBackingStorePixelRatio ||\n", - "\tthis.context.msBackingStorePixelRatio ||\n", - "\tthis.context.oBackingStorePixelRatio ||\n", - "\tthis.context.backingStorePixelRatio || 1;\n", + " var backingStore =\n", + " this.context.backingStorePixelRatio ||\n", + " this.context.webkitBackingStorePixelRatio ||\n", + " this.context.mozBackingStorePixelRatio ||\n", + " this.context.msBackingStorePixelRatio ||\n", + " this.context.oBackingStorePixelRatio ||\n", + " this.context.backingStorePixelRatio ||\n", + " 1;\n", "\n", - " mpl.ratio = (window.devicePixelRatio || 1) / backingStore;\n", + " this.ratio = (window.devicePixelRatio || 1) / backingStore;\n", + "\n", + " var rubberband_canvas = (this.rubberband_canvas = document.createElement(\n", + " 'canvas'\n", + " ));\n", + " rubberband_canvas.setAttribute(\n", + " 'style',\n", + " 'box-sizing: content-box; position: absolute; left: 0; top: 0; z-index: 1;'\n", + " );\n", "\n", - " var rubberband = $('');\n", - " rubberband.attr('style', \"position: absolute; left: 0; top: 0; z-index: 1;\")\n", + " // Apply a ponyfill if ResizeObserver is not implemented by browser.\n", + " if (this.ResizeObserver === undefined) {\n", + " if (window.ResizeObserver !== undefined) {\n", + " this.ResizeObserver = window.ResizeObserver;\n", + " } else {\n", + " var obs = _JSXTOOLS_RESIZE_OBSERVER({});\n", + " this.ResizeObserver = obs.ResizeObserver;\n", + " }\n", + " }\n", "\n", - " var pass_mouse_events = true;\n", + " this.resizeObserverInstance = new this.ResizeObserver(function (entries) {\n", + " var nentries = entries.length;\n", + " for (var i = 0; i < nentries; i++) {\n", + " var entry = entries[i];\n", + " var width, height;\n", + " if (entry.contentBoxSize) {\n", + " if (entry.contentBoxSize instanceof Array) {\n", + " // Chrome 84 implements new version of spec.\n", + " width = entry.contentBoxSize[0].inlineSize;\n", + " height = entry.contentBoxSize[0].blockSize;\n", + " } else {\n", + " // Firefox implements old version of spec.\n", + " width = entry.contentBoxSize.inlineSize;\n", + " height = entry.contentBoxSize.blockSize;\n", + " }\n", + " } else {\n", + " // Chrome <84 implements even older version of spec.\n", + " width = entry.contentRect.width;\n", + " height = entry.contentRect.height;\n", + " }\n", "\n", - " canvas_div.resizable({\n", - " start: function(event, ui) {\n", - " pass_mouse_events = false;\n", - " },\n", - " resize: function(event, ui) {\n", - " fig.request_resize(ui.size.width, ui.size.height);\n", - " },\n", - " stop: function(event, ui) {\n", - " pass_mouse_events = true;\n", - " fig.request_resize(ui.size.width, ui.size.height);\n", - " },\n", + " // Keep the size of the canvas and rubber band canvas in sync with\n", + " // the canvas container.\n", + " if (entry.devicePixelContentBoxSize) {\n", + " // Chrome 84 implements new version of spec.\n", + " canvas.setAttribute(\n", + " 'width',\n", + " entry.devicePixelContentBoxSize[0].inlineSize\n", + " );\n", + " canvas.setAttribute(\n", + " 'height',\n", + " entry.devicePixelContentBoxSize[0].blockSize\n", + " );\n", + " } else {\n", + " canvas.setAttribute('width', width * fig.ratio);\n", + " canvas.setAttribute('height', height * fig.ratio);\n", + " }\n", + " canvas.setAttribute(\n", + " 'style',\n", + " 'width: ' + width + 'px; height: ' + height + 'px;'\n", + " );\n", + "\n", + " rubberband_canvas.setAttribute('width', width);\n", + " rubberband_canvas.setAttribute('height', height);\n", + "\n", + " // And update the size in Python. We ignore the initial 0/0 size\n", + " // that occurs as the element is placed into the DOM, which should\n", + " // otherwise not happen due to the minimum size styling.\n", + " if (fig.ws.readyState == 1 && width != 0 && height != 0) {\n", + " fig.request_resize(width, height);\n", + " }\n", + " }\n", " });\n", + " this.resizeObserverInstance.observe(canvas_div);\n", "\n", - " function mouse_event_fn(event) {\n", - " if (pass_mouse_events)\n", - " return fig.mouse_event(event, event['data']);\n", + " function on_mouse_event_closure(name) {\n", + " return function (event) {\n", + " return fig.mouse_event(event, name);\n", + " };\n", " }\n", "\n", - " rubberband.mousedown('button_press', mouse_event_fn);\n", - " rubberband.mouseup('button_release', mouse_event_fn);\n", + " rubberband_canvas.addEventListener(\n", + " 'mousedown',\n", + " on_mouse_event_closure('button_press')\n", + " );\n", + " rubberband_canvas.addEventListener(\n", + " 'mouseup',\n", + " on_mouse_event_closure('button_release')\n", + " );\n", + " rubberband_canvas.addEventListener(\n", + " 'dblclick',\n", + " on_mouse_event_closure('dblclick')\n", + " );\n", " // Throttle sequential mouse events to 1 every 20ms.\n", - " rubberband.mousemove('motion_notify', mouse_event_fn);\n", + " rubberband_canvas.addEventListener(\n", + " 'mousemove',\n", + " on_mouse_event_closure('motion_notify')\n", + " );\n", "\n", - " rubberband.mouseenter('figure_enter', mouse_event_fn);\n", - " rubberband.mouseleave('figure_leave', mouse_event_fn);\n", + " rubberband_canvas.addEventListener(\n", + " 'mouseenter',\n", + " on_mouse_event_closure('figure_enter')\n", + " );\n", + " rubberband_canvas.addEventListener(\n", + " 'mouseleave',\n", + " on_mouse_event_closure('figure_leave')\n", + " );\n", "\n", - " canvas_div.on(\"wheel\", function (event) {\n", - " event = event.originalEvent;\n", - " event['data'] = 'scroll'\n", + " canvas_div.addEventListener('wheel', function (event) {\n", " if (event.deltaY < 0) {\n", " event.step = 1;\n", " } else {\n", " event.step = -1;\n", " }\n", - " mouse_event_fn(event);\n", + " on_mouse_event_closure('scroll')(event);\n", " });\n", "\n", - " canvas_div.append(canvas);\n", - " canvas_div.append(rubberband);\n", - "\n", - " this.rubberband = rubberband;\n", - " this.rubberband_canvas = rubberband[0];\n", - " this.rubberband_context = rubberband[0].getContext(\"2d\");\n", - " this.rubberband_context.strokeStyle = \"#000000\";\n", + " canvas_div.appendChild(canvas);\n", + " canvas_div.appendChild(rubberband_canvas);\n", "\n", - " this._resize_canvas = function(width, height) {\n", - " // Keep the size of the canvas, canvas container, and rubber band\n", - " // canvas in synch.\n", - " canvas_div.css('width', width)\n", - " canvas_div.css('height', height)\n", + " this.rubberband_context = rubberband_canvas.getContext('2d');\n", + " this.rubberband_context.strokeStyle = '#000000';\n", "\n", - " canvas.attr('width', width * mpl.ratio);\n", - " canvas.attr('height', height * mpl.ratio);\n", - " canvas.attr('style', 'width: ' + width + 'px; height: ' + height + 'px;');\n", - "\n", - " rubberband.attr('width', width);\n", - " rubberband.attr('height', height);\n", - " }\n", - "\n", - " // Set the figure to an initial 600x600px, this will subsequently be updated\n", - " // upon first draw.\n", - " this._resize_canvas(600, 600);\n", + " this._resize_canvas = function (width, height, forward) {\n", + " if (forward) {\n", + " canvas_div.style.width = width + 'px';\n", + " canvas_div.style.height = height + 'px';\n", + " }\n", + " };\n", "\n", " // Disable right mouse context menu.\n", - " $(this.rubberband_canvas).bind(\"contextmenu\",function(e){\n", + " this.rubberband_canvas.addEventListener('contextmenu', function (_e) {\n", + " event.preventDefault();\n", " return false;\n", " });\n", "\n", - " function set_focus () {\n", + " function set_focus() {\n", " canvas.focus();\n", " canvas_div.focus();\n", " }\n", "\n", " window.setTimeout(set_focus, 100);\n", - "}\n", + "};\n", "\n", - "mpl.figure.prototype._init_toolbar = function() {\n", + "mpl.figure.prototype._init_toolbar = function () {\n", " var fig = this;\n", "\n", - " var nav_element = $('
')\n", - " nav_element.attr('style', 'width: 100%');\n", - " this.root.append(nav_element);\n", + " var toolbar = document.createElement('div');\n", + " toolbar.classList = 'mpl-toolbar';\n", + " this.root.appendChild(toolbar);\n", "\n", - " // Define a callback function for later on.\n", - " function toolbar_event(event) {\n", - " return fig.toolbar_button_onclick(event['data']);\n", + " function on_click_closure(name) {\n", + " return function (_event) {\n", + " return fig.toolbar_button_onclick(name);\n", + " };\n", " }\n", - " function toolbar_mouse_event(event) {\n", - " return fig.toolbar_button_onmouseover(event['data']);\n", + "\n", + " function on_mouseover_closure(tooltip) {\n", + " return function (event) {\n", + " if (!event.currentTarget.disabled) {\n", + " return fig.toolbar_button_onmouseover(tooltip);\n", + " }\n", + " };\n", " }\n", "\n", - " for(var toolbar_ind in mpl.toolbar_items) {\n", + " fig.buttons = {};\n", + " var buttonGroup = document.createElement('div');\n", + " buttonGroup.classList = 'mpl-button-group';\n", + " for (var toolbar_ind in mpl.toolbar_items) {\n", " var name = mpl.toolbar_items[toolbar_ind][0];\n", " var tooltip = mpl.toolbar_items[toolbar_ind][1];\n", " var image = mpl.toolbar_items[toolbar_ind][2];\n", " var method_name = mpl.toolbar_items[toolbar_ind][3];\n", "\n", " if (!name) {\n", - " // put a spacer in here.\n", + " /* Instead of a spacer, we start a new button group. */\n", + " if (buttonGroup.hasChildNodes()) {\n", + " toolbar.appendChild(buttonGroup);\n", + " }\n", + " buttonGroup = document.createElement('div');\n", + " buttonGroup.classList = 'mpl-button-group';\n", " continue;\n", " }\n", - " var button = $('');\n", - " button.click(method_name, toolbar_event);\n", - " button.mouseover(tooltip, toolbar_mouse_event);\n", - " nav_element.append(button);\n", - " }\n", - "\n", - " // Add the status bar.\n", - " var status_bar = $('');\n", - " nav_element.append(status_bar);\n", - " this.message = status_bar[0];\n", - "\n", - " // Add the close button to the window.\n", - " var buttongrp = $('
');\n", - " var button = $('');\n", - " button.click(function (evt) { fig.handle_close(fig, {}); } );\n", - " button.mouseover('Stop Interaction', toolbar_mouse_event);\n", - " buttongrp.append(button);\n", - " var titlebar = this.root.find($('.ui-dialog-titlebar'));\n", - " titlebar.prepend(buttongrp);\n", - "}\n", - "\n", - "mpl.figure.prototype._root_extra_style = function(el){\n", - " var fig = this\n", - " el.on(\"remove\", function(){\n", - "\tfig.close_ws(fig, {});\n", - " });\n", - "}\n", - "\n", - "mpl.figure.prototype._canvas_extra_style = function(el){\n", - " // this is important to make the div 'focusable\n", - " el.attr('tabindex', 0)\n", - " // reach out to IPython and tell the keyboard manager to turn it's self\n", - " // off when our div gets focus\n", - "\n", - " // location in version 3\n", - " if (IPython.notebook.keyboard_manager) {\n", - " IPython.notebook.keyboard_manager.register_events(el);\n", - " }\n", - " else {\n", - " // location in version 2\n", - " IPython.keyboard_manager.register_events(el);\n", - " }\n", - "\n", - "}\n", - "\n", - "mpl.figure.prototype._key_event_extra = function(event, name) {\n", - " var manager = IPython.notebook.keyboard_manager;\n", - " if (!manager)\n", - " manager = IPython.keyboard_manager;\n", - "\n", - " // Check for shift+enter\n", - " if (event.shiftKey && event.which == 13) {\n", - " this.canvas_div.blur();\n", - " event.shiftKey = false;\n", - " // Send a \"J\" for go to next cell\n", - " event.which = 74;\n", - " event.keyCode = 74;\n", - " manager.command_mode();\n", - " manager.handle_keydown(event);\n", - " }\n", - "}\n", - "\n", - "mpl.figure.prototype.handle_save = function(fig, msg) {\n", - " fig.ondownload(fig, null);\n", - "}\n", - "\n", - "\n", - "mpl.find_output_cell = function(html_output) {\n", - " // Return the cell and output element which can be found *uniquely* in the notebook.\n", - " // Note - this is a bit hacky, but it is done because the \"notebook_saving.Notebook\"\n", - " // IPython event is triggered only after the cells have been serialised, which for\n", - " // our purposes (turning an active figure into a static one), is too late.\n", - " var cells = IPython.notebook.get_cells();\n", - " var ncells = cells.length;\n", - " for (var i=0; i= 3 moved mimebundle to data attribute of output\n", - " data = data.data;\n", - " }\n", - " if (data['text/html'] == html_output) {\n", - " return [cell, data, j];\n", - " }\n", - " }\n", - " }\n", - " }\n", - "}\n", - "\n", - "// Register the function which deals with the matplotlib target/channel.\n", - "// The kernel may be null if the page has been refreshed.\n", - "if (IPython.notebook.kernel != null) {\n", - " IPython.notebook.kernel.comm_manager.register_target('matplotlib', mpl.mpl_figure_comm);\n", - "}\n" - ], - "text/plain": [ - "" - ] - }, - "metadata": {}, - "output_type": "display_data" - }, - { - "data": { - "text/html": [ - "" - ], - "text/plain": [ - "" - ] - }, - "metadata": {}, - "output_type": "display_data" - } - ], - "source": [ - "fig = plt.figure()\n", - "out = ct.bode_plot([pt1_w001hzi, pt1_w001hzis])" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "### Combination of various systems" - ] - }, - { - "cell_type": "code", - "execution_count": 12, - "metadata": {}, - "outputs": [ - { - "data": { - "application/javascript": [ - "/* Put everything inside the global mpl namespace */\n", - "window.mpl = {};\n", - "\n", - "\n", - "mpl.get_websocket_type = function() {\n", - " if (typeof(WebSocket) !== 'undefined') {\n", - " return WebSocket;\n", - " } else if (typeof(MozWebSocket) !== 'undefined') {\n", - " return MozWebSocket;\n", - " } else {\n", - " alert('Your browser does not have WebSocket support.' +\n", - " 'Please try Chrome, Safari or Firefox ≥ 6. ' +\n", - " 'Firefox 4 and 5 are also supported but you ' +\n", - " 'have to enable WebSockets in about:config.');\n", - " };\n", - "}\n", - "\n", - "mpl.figure = function(figure_id, websocket, ondownload, parent_element) {\n", - " this.id = figure_id;\n", - "\n", - " this.ws = websocket;\n", - "\n", - " this.supports_binary = (this.ws.binaryType != undefined);\n", - "\n", - " if (!this.supports_binary) {\n", - " var warnings = document.getElementById(\"mpl-warnings\");\n", - " if (warnings) {\n", - " warnings.style.display = 'block';\n", - " warnings.textContent = (\n", - " \"This browser does not support binary websocket messages. \" +\n", - " \"Performance may be slow.\");\n", - " }\n", - " }\n", - "\n", - " this.imageObj = new Image();\n", - "\n", - " this.context = undefined;\n", - " this.message = undefined;\n", - " this.canvas = undefined;\n", - " this.rubberband_canvas = undefined;\n", - " this.rubberband_context = undefined;\n", - " this.format_dropdown = undefined;\n", - "\n", - " this.image_mode = 'full';\n", - "\n", - " this.root = $('
');\n", - " this._root_extra_style(this.root)\n", - " this.root.attr('style', 'display: inline-block');\n", - "\n", - " $(parent_element).append(this.root);\n", - "\n", - " this._init_header(this);\n", - " this._init_canvas(this);\n", - " this._init_toolbar(this);\n", - "\n", - " var fig = this;\n", - "\n", - " this.waiting = false;\n", - "\n", - " this.ws.onopen = function () {\n", - " fig.send_message(\"supports_binary\", {value: fig.supports_binary});\n", - " fig.send_message(\"send_image_mode\", {});\n", - " if (mpl.ratio != 1) {\n", - " fig.send_message(\"set_dpi_ratio\", {'dpi_ratio': mpl.ratio});\n", - " }\n", - " fig.send_message(\"refresh\", {});\n", - " }\n", - "\n", - " this.imageObj.onload = function() {\n", - " if (fig.image_mode == 'full') {\n", - " // Full images could contain transparency (where diff images\n", - " // almost always do), so we need to clear the canvas so that\n", - " // there is no ghosting.\n", - " fig.context.clearRect(0, 0, fig.canvas.width, fig.canvas.height);\n", - " }\n", - " fig.context.drawImage(fig.imageObj, 0, 0);\n", - " };\n", - "\n", - " this.imageObj.onunload = function() {\n", - " fig.ws.close();\n", - " }\n", - "\n", - " this.ws.onmessage = this._make_on_message_function(this);\n", - "\n", - " this.ondownload = ondownload;\n", - "}\n", - "\n", - "mpl.figure.prototype._init_header = function() {\n", - " var titlebar = $(\n", - " '
');\n", - " var titletext = $(\n", - " '
');\n", - " titlebar.append(titletext)\n", - " this.root.append(titlebar);\n", - " this.header = titletext[0];\n", - "}\n", - "\n", - "\n", - "\n", - "mpl.figure.prototype._canvas_extra_style = function(canvas_div) {\n", - "\n", - "}\n", - "\n", - "\n", - "mpl.figure.prototype._root_extra_style = function(canvas_div) {\n", - "\n", - "}\n", - "\n", - "mpl.figure.prototype._init_canvas = function() {\n", - " var fig = this;\n", - "\n", - " var canvas_div = $('
');\n", - "\n", - " canvas_div.attr('style', 'position: relative; clear: both; outline: 0');\n", - "\n", - " function canvas_keyboard_event(event) {\n", - " return fig.key_event(event, event['data']);\n", - " }\n", - "\n", - " canvas_div.keydown('key_press', canvas_keyboard_event);\n", - " canvas_div.keyup('key_release', canvas_keyboard_event);\n", - " this.canvas_div = canvas_div\n", - " this._canvas_extra_style(canvas_div)\n", - " this.root.append(canvas_div);\n", - "\n", - " var canvas = $('');\n", - " canvas.addClass('mpl-canvas');\n", - " canvas.attr('style', \"left: 0; top: 0; z-index: 0; outline: 0\")\n", - "\n", - " this.canvas = canvas[0];\n", - " this.context = canvas[0].getContext(\"2d\");\n", - "\n", - " var backingStore = this.context.backingStorePixelRatio ||\n", - "\tthis.context.webkitBackingStorePixelRatio ||\n", - "\tthis.context.mozBackingStorePixelRatio ||\n", - "\tthis.context.msBackingStorePixelRatio ||\n", - "\tthis.context.oBackingStorePixelRatio ||\n", - "\tthis.context.backingStorePixelRatio || 1;\n", - "\n", - " mpl.ratio = (window.devicePixelRatio || 1) / backingStore;\n", - "\n", - " var rubberband = $('');\n", - " rubberband.attr('style', \"position: absolute; left: 0; top: 0; z-index: 1;\")\n", - "\n", - " var pass_mouse_events = true;\n", - "\n", - " canvas_div.resizable({\n", - " start: function(event, ui) {\n", - " pass_mouse_events = false;\n", - " },\n", - " resize: function(event, ui) {\n", - " fig.request_resize(ui.size.width, ui.size.height);\n", - " },\n", - " stop: function(event, ui) {\n", - " pass_mouse_events = true;\n", - " fig.request_resize(ui.size.width, ui.size.height);\n", - " },\n", - " });\n", - "\n", - " function mouse_event_fn(event) {\n", - " if (pass_mouse_events)\n", - " return fig.mouse_event(event, event['data']);\n", - " }\n", - "\n", - " rubberband.mousedown('button_press', mouse_event_fn);\n", - " rubberband.mouseup('button_release', mouse_event_fn);\n", - " // Throttle sequential mouse events to 1 every 20ms.\n", - " rubberband.mousemove('motion_notify', mouse_event_fn);\n", - "\n", - " rubberband.mouseenter('figure_enter', mouse_event_fn);\n", - " rubberband.mouseleave('figure_leave', mouse_event_fn);\n", - "\n", - " canvas_div.on(\"wheel\", function (event) {\n", - " event = event.originalEvent;\n", - " event['data'] = 'scroll'\n", - " if (event.deltaY < 0) {\n", - " event.step = 1;\n", - " } else {\n", - " event.step = -1;\n", - " }\n", - " mouse_event_fn(event);\n", - " });\n", - "\n", - " canvas_div.append(canvas);\n", - " canvas_div.append(rubberband);\n", - "\n", - " this.rubberband = rubberband;\n", - " this.rubberband_canvas = rubberband[0];\n", - " this.rubberband_context = rubberband[0].getContext(\"2d\");\n", - " this.rubberband_context.strokeStyle = \"#000000\";\n", - "\n", - " this._resize_canvas = function(width, height) {\n", - " // Keep the size of the canvas, canvas container, and rubber band\n", - " // canvas in synch.\n", - " canvas_div.css('width', width)\n", - " canvas_div.css('height', height)\n", - "\n", - " canvas.attr('width', width * mpl.ratio);\n", - " canvas.attr('height', height * mpl.ratio);\n", - " canvas.attr('style', 'width: ' + width + 'px; height: ' + height + 'px;');\n", - "\n", - " rubberband.attr('width', width);\n", - " rubberband.attr('height', height);\n", - " }\n", - "\n", - " // Set the figure to an initial 600x600px, this will subsequently be updated\n", - " // upon first draw.\n", - " this._resize_canvas(600, 600);\n", - "\n", - " // Disable right mouse context menu.\n", - " $(this.rubberband_canvas).bind(\"contextmenu\",function(e){\n", - " return false;\n", - " });\n", - "\n", - " function set_focus () {\n", - " canvas.focus();\n", - " canvas_div.focus();\n", - " }\n", - "\n", - " window.setTimeout(set_focus, 100);\n", - "}\n", - "\n", - "mpl.figure.prototype._init_toolbar = function() {\n", - " var fig = this;\n", - "\n", - " var nav_element = $('
')\n", - " nav_element.attr('style', 'width: 100%');\n", - " this.root.append(nav_element);\n", - "\n", - " // Define a callback function for later on.\n", - " function toolbar_event(event) {\n", - " return fig.toolbar_button_onclick(event['data']);\n", - " }\n", - " function toolbar_mouse_event(event) {\n", - " return fig.toolbar_button_onmouseover(event['data']);\n", - " }\n", - "\n", - " for(var toolbar_ind in mpl.toolbar_items) {\n", - " var name = mpl.toolbar_items[toolbar_ind][0];\n", - " var tooltip = mpl.toolbar_items[toolbar_ind][1];\n", - " var image = mpl.toolbar_items[toolbar_ind][2];\n", - " var method_name = mpl.toolbar_items[toolbar_ind][3];\n", - "\n", - " if (!name) {\n", - " // put a spacer in here.\n", - " continue;\n", - " }\n", - " var button = $('');\n", - " button.click(method_name, toolbar_event);\n", - " button.mouseover(tooltip, toolbar_mouse_event);\n", - " nav_element.append(button);\n", + " if (buttonGroup.hasChildNodes()) {\n", + " toolbar.appendChild(buttonGroup);\n", " }\n", "\n", " // Add the status bar.\n", - " var status_bar = $('');\n", - " nav_element.append(status_bar);\n", - " this.message = status_bar[0];\n", + " var status_bar = document.createElement('span');\n", + " status_bar.classList = 'mpl-message pull-right';\n", + " toolbar.appendChild(status_bar);\n", + " this.message = status_bar;\n", "\n", " // Add the close button to the window.\n", - " var buttongrp = $('
');\n", - " var button = $('');\n", - " button.click(function (evt) { fig.handle_close(fig, {}); } );\n", - " button.mouseover('Stop Interaction', toolbar_mouse_event);\n", - " buttongrp.append(button);\n", - " var titlebar = this.root.find($('.ui-dialog-titlebar'));\n", - " titlebar.prepend(buttongrp);\n", - "}\n", - "\n", - "mpl.figure.prototype._root_extra_style = function(el){\n", - " var fig = this\n", - " el.on(\"remove\", function(){\n", - "\tfig.close_ws(fig, {});\n", + " var buttongrp = document.createElement('div');\n", + " buttongrp.classList = 'btn-group inline pull-right';\n", + " button = document.createElement('button');\n", + " button.classList = 'btn btn-mini btn-primary';\n", + " button.href = '#';\n", + " button.title = 'Stop Interaction';\n", + " button.innerHTML = '';\n", + " button.addEventListener('click', function (_evt) {\n", + " fig.handle_close(fig, {});\n", " });\n", - "}\n", + " button.addEventListener(\n", + " 'mouseover',\n", + " on_mouseover_closure('Stop Interaction')\n", + " );\n", + " buttongrp.appendChild(button);\n", + " var titlebar = this.root.querySelector('.ui-dialog-titlebar');\n", + " titlebar.insertBefore(buttongrp, titlebar.firstChild);\n", + "};\n", "\n", - "mpl.figure.prototype._canvas_extra_style = function(el){\n", + "mpl.figure.prototype._remove_fig_handler = function (event) {\n", + " var fig = event.data.fig;\n", + " if (event.target !== this) {\n", + " // Ignore bubbled events from children.\n", + " return;\n", + " }\n", + " fig.close_ws(fig, {});\n", + "};\n", + "\n", + "mpl.figure.prototype._root_extra_style = function (el) {\n", + " el.style.boxSizing = 'content-box'; // override notebook setting of border-box.\n", + "};\n", + "\n", + "mpl.figure.prototype._canvas_extra_style = function (el) {\n", " // this is important to make the div 'focusable\n", - " el.attr('tabindex', 0)\n", + " el.setAttribute('tabindex', 0);\n", " // reach out to IPython and tell the keyboard manager to turn it's self\n", " // off when our div gets focus\n", "\n", " // location in version 3\n", " if (IPython.notebook.keyboard_manager) {\n", " IPython.notebook.keyboard_manager.register_events(el);\n", - " }\n", - " else {\n", + " } else {\n", " // location in version 2\n", " IPython.keyboard_manager.register_events(el);\n", " }\n", + "};\n", "\n", - "}\n", - "\n", - "mpl.figure.prototype._key_event_extra = function(event, name) {\n", - " var manager = IPython.notebook.keyboard_manager;\n", - " if (!manager)\n", - " manager = IPython.keyboard_manager;\n", - "\n", + "mpl.figure.prototype._key_event_extra = function (event, _name) {\n", " // Check for shift+enter\n", - " if (event.shiftKey && event.which == 13) {\n", + " if (event.shiftKey && event.which === 13) {\n", " this.canvas_div.blur();\n", - " event.shiftKey = false;\n", - " // Send a \"J\" for go to next cell\n", - " event.which = 74;\n", - " event.keyCode = 74;\n", - " manager.command_mode();\n", - " manager.handle_keydown(event);\n", + " // select the cell after this one\n", + " var index = IPython.notebook.find_cell_index(this.cell_info[0]);\n", + " IPython.notebook.select(index + 1);\n", " }\n", - "}\n", + "};\n", "\n", - "mpl.figure.prototype.handle_save = function(fig, msg) {\n", + "mpl.figure.prototype.handle_save = function (fig, _msg) {\n", " fig.ondownload(fig, null);\n", - "}\n", - "\n", + "};\n", "\n", - "mpl.find_output_cell = function(html_output) {\n", + "mpl.find_output_cell = function (html_output) {\n", " // Return the cell and output element which can be found *uniquely* in the notebook.\n", " // Note - this is a bit hacky, but it is done because the \"notebook_saving.Notebook\"\n", " // IPython event is triggered only after the cells have been serialised, which for\n", " // our purposes (turning an active figure into a static one), is too late.\n", " var cells = IPython.notebook.get_cells();\n", " var ncells = cells.length;\n", - " for (var i=0; i= 3 moved mimebundle to data attribute of output\n", " data = data.data;\n", " }\n", - " if (data['text/html'] == html_output) {\n", + " if (data['text/html'] === html_output) {\n", " return [cell, data, j];\n", " }\n", " }\n", " }\n", " }\n", - "}\n", + "};\n", "\n", "// Register the function which deals with the matplotlib target/channel.\n", "// The kernel may be null if the page has been refreshed.\n", - "if (IPython.notebook.kernel != null) {\n", - " IPython.notebook.kernel.comm_manager.register_target('matplotlib', mpl.mpl_figure_comm);\n", + "if (IPython.notebook.kernel !== null) {\n", + " IPython.notebook.kernel.comm_manager.register_target(\n", + " 'matplotlib',\n", + " mpl.mpl_figure_comm\n", + " );\n", "}\n" ], "text/plain": [ @@ -6761,7 +7216,7 @@ { "data": { "text/html": [ - "" + "" ], "text/plain": [ "" @@ -6772,7 +7227,7 @@ } ], "source": [ - "ct.config.bode_feature_periphery_decade = 3.5\n", + "ct.config.defaults['freqplot.feature_periphery_decades'] = 3.5\n", "fig = plt.figure()\n", "out = ct.bode_plot([pt1_w001hzi, pt1_w001hzis], Hz=True)" ] @@ -6793,36 +7248,38 @@ "data": { "application/javascript": [ "/* Put everything inside the global mpl namespace */\n", + "/* global mpl */\n", "window.mpl = {};\n", "\n", - "\n", - "mpl.get_websocket_type = function() {\n", - " if (typeof(WebSocket) !== 'undefined') {\n", + "mpl.get_websocket_type = function () {\n", + " if (typeof WebSocket !== 'undefined') {\n", " return WebSocket;\n", - " } else if (typeof(MozWebSocket) !== 'undefined') {\n", + " } else if (typeof MozWebSocket !== 'undefined') {\n", " return MozWebSocket;\n", " } else {\n", - " alert('Your browser does not have WebSocket support.' +\n", - " 'Please try Chrome, Safari or Firefox ≥ 6. ' +\n", - " 'Firefox 4 and 5 are also supported but you ' +\n", - " 'have to enable WebSockets in about:config.');\n", - " };\n", - "}\n", + " alert(\n", + " 'Your browser does not have WebSocket support. ' +\n", + " 'Please try Chrome, Safari or Firefox ≥ 6. ' +\n", + " 'Firefox 4 and 5 are also supported but you ' +\n", + " 'have to enable WebSockets in about:config.'\n", + " );\n", + " }\n", + "};\n", "\n", - "mpl.figure = function(figure_id, websocket, ondownload, parent_element) {\n", + "mpl.figure = function (figure_id, websocket, ondownload, parent_element) {\n", " this.id = figure_id;\n", "\n", " this.ws = websocket;\n", "\n", - " this.supports_binary = (this.ws.binaryType != undefined);\n", + " this.supports_binary = this.ws.binaryType !== undefined;\n", "\n", " if (!this.supports_binary) {\n", - " var warnings = document.getElementById(\"mpl-warnings\");\n", + " var warnings = document.getElementById('mpl-warnings');\n", " if (warnings) {\n", " warnings.style.display = 'block';\n", - " warnings.textContent = (\n", - " \"This browser does not support binary websocket messages. \" +\n", - " \"Performance may be slow.\");\n", + " warnings.textContent =\n", + " 'This browser does not support binary websocket messages. ' +\n", + " 'Performance may be slow.';\n", " }\n", " }\n", "\n", @@ -6837,11 +7294,11 @@ "\n", " this.image_mode = 'full';\n", "\n", - " this.root = $('
');\n", - " this._root_extra_style(this.root)\n", - " this.root.attr('style', 'display: inline-block');\n", + " this.root = document.createElement('div');\n", + " this.root.setAttribute('style', 'display: inline-block');\n", + " this._root_extra_style(this.root);\n", "\n", - " $(parent_element).append(this.root);\n", + " parent_element.appendChild(this.root);\n", "\n", " this._init_header(this);\n", " this._init_canvas(this);\n", @@ -6851,285 +7308,366 @@ "\n", " this.waiting = false;\n", "\n", - " this.ws.onopen = function () {\n", - " fig.send_message(\"supports_binary\", {value: fig.supports_binary});\n", - " fig.send_message(\"send_image_mode\", {});\n", - " if (mpl.ratio != 1) {\n", - " fig.send_message(\"set_dpi_ratio\", {'dpi_ratio': mpl.ratio});\n", - " }\n", - " fig.send_message(\"refresh\", {});\n", + " this.ws.onopen = function () {\n", + " fig.send_message('supports_binary', { value: fig.supports_binary });\n", + " fig.send_message('send_image_mode', {});\n", + " if (fig.ratio !== 1) {\n", + " fig.send_message('set_device_pixel_ratio', {\n", + " device_pixel_ratio: fig.ratio,\n", + " });\n", " }\n", + " fig.send_message('refresh', {});\n", + " };\n", "\n", - " this.imageObj.onload = function() {\n", - " if (fig.image_mode == 'full') {\n", - " // Full images could contain transparency (where diff images\n", - " // almost always do), so we need to clear the canvas so that\n", - " // there is no ghosting.\n", - " fig.context.clearRect(0, 0, fig.canvas.width, fig.canvas.height);\n", - " }\n", - " fig.context.drawImage(fig.imageObj, 0, 0);\n", - " };\n", + " this.imageObj.onload = function () {\n", + " if (fig.image_mode === 'full') {\n", + " // Full images could contain transparency (where diff images\n", + " // almost always do), so we need to clear the canvas so that\n", + " // there is no ghosting.\n", + " fig.context.clearRect(0, 0, fig.canvas.width, fig.canvas.height);\n", + " }\n", + " fig.context.drawImage(fig.imageObj, 0, 0);\n", + " };\n", "\n", - " this.imageObj.onunload = function() {\n", + " this.imageObj.onunload = function () {\n", " fig.ws.close();\n", - " }\n", + " };\n", "\n", " this.ws.onmessage = this._make_on_message_function(this);\n", "\n", " this.ondownload = ondownload;\n", - "}\n", - "\n", - "mpl.figure.prototype._init_header = function() {\n", - " var titlebar = $(\n", - " '
');\n", - " var titletext = $(\n", - " '
');\n", - " titlebar.append(titletext)\n", - " this.root.append(titlebar);\n", - " this.header = titletext[0];\n", - "}\n", - "\n", - "\n", - "\n", - "mpl.figure.prototype._canvas_extra_style = function(canvas_div) {\n", - "\n", - "}\n", + "};\n", "\n", + "mpl.figure.prototype._init_header = function () {\n", + " var titlebar = document.createElement('div');\n", + " titlebar.classList =\n", + " 'ui-dialog-titlebar ui-widget-header ui-corner-all ui-helper-clearfix';\n", + " var titletext = document.createElement('div');\n", + " titletext.classList = 'ui-dialog-title';\n", + " titletext.setAttribute(\n", + " 'style',\n", + " 'width: 100%; text-align: center; padding: 3px;'\n", + " );\n", + " titlebar.appendChild(titletext);\n", + " this.root.appendChild(titlebar);\n", + " this.header = titletext;\n", + "};\n", "\n", - "mpl.figure.prototype._root_extra_style = function(canvas_div) {\n", + "mpl.figure.prototype._canvas_extra_style = function (_canvas_div) {};\n", "\n", - "}\n", + "mpl.figure.prototype._root_extra_style = function (_canvas_div) {};\n", "\n", - "mpl.figure.prototype._init_canvas = function() {\n", + "mpl.figure.prototype._init_canvas = function () {\n", " var fig = this;\n", "\n", - " var canvas_div = $('
');\n", - "\n", - " canvas_div.attr('style', 'position: relative; clear: both; outline: 0');\n", + " var canvas_div = (this.canvas_div = document.createElement('div'));\n", + " canvas_div.setAttribute(\n", + " 'style',\n", + " 'border: 1px solid #ddd;' +\n", + " 'box-sizing: content-box;' +\n", + " 'clear: both;' +\n", + " 'min-height: 1px;' +\n", + " 'min-width: 1px;' +\n", + " 'outline: 0;' +\n", + " 'overflow: hidden;' +\n", + " 'position: relative;' +\n", + " 'resize: both;'\n", + " );\n", "\n", - " function canvas_keyboard_event(event) {\n", - " return fig.key_event(event, event['data']);\n", + " function on_keyboard_event_closure(name) {\n", + " return function (event) {\n", + " return fig.key_event(event, name);\n", + " };\n", " }\n", "\n", - " canvas_div.keydown('key_press', canvas_keyboard_event);\n", - " canvas_div.keyup('key_release', canvas_keyboard_event);\n", - " this.canvas_div = canvas_div\n", - " this._canvas_extra_style(canvas_div)\n", - " this.root.append(canvas_div);\n", + " canvas_div.addEventListener(\n", + " 'keydown',\n", + " on_keyboard_event_closure('key_press')\n", + " );\n", + " canvas_div.addEventListener(\n", + " 'keyup',\n", + " on_keyboard_event_closure('key_release')\n", + " );\n", + "\n", + " this._canvas_extra_style(canvas_div);\n", + " this.root.appendChild(canvas_div);\n", + "\n", + " var canvas = (this.canvas = document.createElement('canvas'));\n", + " canvas.classList.add('mpl-canvas');\n", + " canvas.setAttribute('style', 'box-sizing: content-box;');\n", "\n", - " var canvas = $('');\n", - " canvas.addClass('mpl-canvas');\n", - " canvas.attr('style', \"left: 0; top: 0; z-index: 0; outline: 0\")\n", + " this.context = canvas.getContext('2d');\n", "\n", - " this.canvas = canvas[0];\n", - " this.context = canvas[0].getContext(\"2d\");\n", + " var backingStore =\n", + " this.context.backingStorePixelRatio ||\n", + " this.context.webkitBackingStorePixelRatio ||\n", + " this.context.mozBackingStorePixelRatio ||\n", + " this.context.msBackingStorePixelRatio ||\n", + " this.context.oBackingStorePixelRatio ||\n", + " this.context.backingStorePixelRatio ||\n", + " 1;\n", "\n", - " var backingStore = this.context.backingStorePixelRatio ||\n", - "\tthis.context.webkitBackingStorePixelRatio ||\n", - "\tthis.context.mozBackingStorePixelRatio ||\n", - "\tthis.context.msBackingStorePixelRatio ||\n", - "\tthis.context.oBackingStorePixelRatio ||\n", - "\tthis.context.backingStorePixelRatio || 1;\n", + " this.ratio = (window.devicePixelRatio || 1) / backingStore;\n", "\n", - " mpl.ratio = (window.devicePixelRatio || 1) / backingStore;\n", + " var rubberband_canvas = (this.rubberband_canvas = document.createElement(\n", + " 'canvas'\n", + " ));\n", + " rubberband_canvas.setAttribute(\n", + " 'style',\n", + " 'box-sizing: content-box; position: absolute; left: 0; top: 0; z-index: 1;'\n", + " );\n", "\n", - " var rubberband = $('');\n", - " rubberband.attr('style', \"position: absolute; left: 0; top: 0; z-index: 1;\")\n", + " // Apply a ponyfill if ResizeObserver is not implemented by browser.\n", + " if (this.ResizeObserver === undefined) {\n", + " if (window.ResizeObserver !== undefined) {\n", + " this.ResizeObserver = window.ResizeObserver;\n", + " } else {\n", + " var obs = _JSXTOOLS_RESIZE_OBSERVER({});\n", + " this.ResizeObserver = obs.ResizeObserver;\n", + " }\n", + " }\n", "\n", - " var pass_mouse_events = true;\n", + " this.resizeObserverInstance = new this.ResizeObserver(function (entries) {\n", + " var nentries = entries.length;\n", + " for (var i = 0; i < nentries; i++) {\n", + " var entry = entries[i];\n", + " var width, height;\n", + " if (entry.contentBoxSize) {\n", + " if (entry.contentBoxSize instanceof Array) {\n", + " // Chrome 84 implements new version of spec.\n", + " width = entry.contentBoxSize[0].inlineSize;\n", + " height = entry.contentBoxSize[0].blockSize;\n", + " } else {\n", + " // Firefox implements old version of spec.\n", + " width = entry.contentBoxSize.inlineSize;\n", + " height = entry.contentBoxSize.blockSize;\n", + " }\n", + " } else {\n", + " // Chrome <84 implements even older version of spec.\n", + " width = entry.contentRect.width;\n", + " height = entry.contentRect.height;\n", + " }\n", "\n", - " canvas_div.resizable({\n", - " start: function(event, ui) {\n", - " pass_mouse_events = false;\n", - " },\n", - " resize: function(event, ui) {\n", - " fig.request_resize(ui.size.width, ui.size.height);\n", - " },\n", - " stop: function(event, ui) {\n", - " pass_mouse_events = true;\n", - " fig.request_resize(ui.size.width, ui.size.height);\n", - " },\n", + " // Keep the size of the canvas and rubber band canvas in sync with\n", + " // the canvas container.\n", + " if (entry.devicePixelContentBoxSize) {\n", + " // Chrome 84 implements new version of spec.\n", + " canvas.setAttribute(\n", + " 'width',\n", + " entry.devicePixelContentBoxSize[0].inlineSize\n", + " );\n", + " canvas.setAttribute(\n", + " 'height',\n", + " entry.devicePixelContentBoxSize[0].blockSize\n", + " );\n", + " } else {\n", + " canvas.setAttribute('width', width * fig.ratio);\n", + " canvas.setAttribute('height', height * fig.ratio);\n", + " }\n", + " canvas.setAttribute(\n", + " 'style',\n", + " 'width: ' + width + 'px; height: ' + height + 'px;'\n", + " );\n", + "\n", + " rubberband_canvas.setAttribute('width', width);\n", + " rubberband_canvas.setAttribute('height', height);\n", + "\n", + " // And update the size in Python. We ignore the initial 0/0 size\n", + " // that occurs as the element is placed into the DOM, which should\n", + " // otherwise not happen due to the minimum size styling.\n", + " if (fig.ws.readyState == 1 && width != 0 && height != 0) {\n", + " fig.request_resize(width, height);\n", + " }\n", + " }\n", " });\n", + " this.resizeObserverInstance.observe(canvas_div);\n", "\n", - " function mouse_event_fn(event) {\n", - " if (pass_mouse_events)\n", - " return fig.mouse_event(event, event['data']);\n", + " function on_mouse_event_closure(name) {\n", + " return function (event) {\n", + " return fig.mouse_event(event, name);\n", + " };\n", " }\n", "\n", - " rubberband.mousedown('button_press', mouse_event_fn);\n", - " rubberband.mouseup('button_release', mouse_event_fn);\n", + " rubberband_canvas.addEventListener(\n", + " 'mousedown',\n", + " on_mouse_event_closure('button_press')\n", + " );\n", + " rubberband_canvas.addEventListener(\n", + " 'mouseup',\n", + " on_mouse_event_closure('button_release')\n", + " );\n", + " rubberband_canvas.addEventListener(\n", + " 'dblclick',\n", + " on_mouse_event_closure('dblclick')\n", + " );\n", " // Throttle sequential mouse events to 1 every 20ms.\n", - " rubberband.mousemove('motion_notify', mouse_event_fn);\n", + " rubberband_canvas.addEventListener(\n", + " 'mousemove',\n", + " on_mouse_event_closure('motion_notify')\n", + " );\n", "\n", - " rubberband.mouseenter('figure_enter', mouse_event_fn);\n", - " rubberband.mouseleave('figure_leave', mouse_event_fn);\n", + " rubberband_canvas.addEventListener(\n", + " 'mouseenter',\n", + " on_mouse_event_closure('figure_enter')\n", + " );\n", + " rubberband_canvas.addEventListener(\n", + " 'mouseleave',\n", + " on_mouse_event_closure('figure_leave')\n", + " );\n", "\n", - " canvas_div.on(\"wheel\", function (event) {\n", - " event = event.originalEvent;\n", - " event['data'] = 'scroll'\n", + " canvas_div.addEventListener('wheel', function (event) {\n", " if (event.deltaY < 0) {\n", " event.step = 1;\n", " } else {\n", " event.step = -1;\n", " }\n", - " mouse_event_fn(event);\n", + " on_mouse_event_closure('scroll')(event);\n", " });\n", "\n", - " canvas_div.append(canvas);\n", - " canvas_div.append(rubberband);\n", + " canvas_div.appendChild(canvas);\n", + " canvas_div.appendChild(rubberband_canvas);\n", "\n", - " this.rubberband = rubberband;\n", - " this.rubberband_canvas = rubberband[0];\n", - " this.rubberband_context = rubberband[0].getContext(\"2d\");\n", - " this.rubberband_context.strokeStyle = \"#000000\";\n", - "\n", - " this._resize_canvas = function(width, height) {\n", - " // Keep the size of the canvas, canvas container, and rubber band\n", - " // canvas in synch.\n", - " canvas_div.css('width', width)\n", - " canvas_div.css('height', height)\n", - "\n", - " canvas.attr('width', width * mpl.ratio);\n", - " canvas.attr('height', height * mpl.ratio);\n", - " canvas.attr('style', 'width: ' + width + 'px; height: ' + height + 'px;');\n", - "\n", - " rubberband.attr('width', width);\n", - " rubberband.attr('height', height);\n", - " }\n", + " this.rubberband_context = rubberband_canvas.getContext('2d');\n", + " this.rubberband_context.strokeStyle = '#000000';\n", "\n", - " // Set the figure to an initial 600x600px, this will subsequently be updated\n", - " // upon first draw.\n", - " this._resize_canvas(600, 600);\n", + " this._resize_canvas = function (width, height, forward) {\n", + " if (forward) {\n", + " canvas_div.style.width = width + 'px';\n", + " canvas_div.style.height = height + 'px';\n", + " }\n", + " };\n", "\n", " // Disable right mouse context menu.\n", - " $(this.rubberband_canvas).bind(\"contextmenu\",function(e){\n", + " this.rubberband_canvas.addEventListener('contextmenu', function (_e) {\n", + " event.preventDefault();\n", " return false;\n", " });\n", "\n", - " function set_focus () {\n", + " function set_focus() {\n", " canvas.focus();\n", " canvas_div.focus();\n", " }\n", "\n", " window.setTimeout(set_focus, 100);\n", - "}\n", + "};\n", "\n", - "mpl.figure.prototype._init_toolbar = function() {\n", + "mpl.figure.prototype._init_toolbar = function () {\n", " var fig = this;\n", "\n", - " var nav_element = $('
')\n", - " nav_element.attr('style', 'width: 100%');\n", - " this.root.append(nav_element);\n", + " var toolbar = document.createElement('div');\n", + " toolbar.classList = 'mpl-toolbar';\n", + " this.root.appendChild(toolbar);\n", "\n", - " // Define a callback function for later on.\n", - " function toolbar_event(event) {\n", - " return fig.toolbar_button_onclick(event['data']);\n", + " function on_click_closure(name) {\n", + " return function (_event) {\n", + " return fig.toolbar_button_onclick(name);\n", + " };\n", " }\n", - " function toolbar_mouse_event(event) {\n", - " return fig.toolbar_button_onmouseover(event['data']);\n", + "\n", + " function on_mouseover_closure(tooltip) {\n", + " return function (event) {\n", + " if (!event.currentTarget.disabled) {\n", + " return fig.toolbar_button_onmouseover(tooltip);\n", + " }\n", + " };\n", " }\n", "\n", - " for(var toolbar_ind in mpl.toolbar_items) {\n", + " fig.buttons = {};\n", + " var buttonGroup = document.createElement('div');\n", + " buttonGroup.classList = 'mpl-button-group';\n", + " for (var toolbar_ind in mpl.toolbar_items) {\n", " var name = mpl.toolbar_items[toolbar_ind][0];\n", " var tooltip = mpl.toolbar_items[toolbar_ind][1];\n", " var image = mpl.toolbar_items[toolbar_ind][2];\n", " var method_name = mpl.toolbar_items[toolbar_ind][3];\n", "\n", " if (!name) {\n", - " // put a spacer in here.\n", + " /* Instead of a spacer, we start a new button group. */\n", + " if (buttonGroup.hasChildNodes()) {\n", + " toolbar.appendChild(buttonGroup);\n", + " }\n", + " buttonGroup = document.createElement('div');\n", + " buttonGroup.classList = 'mpl-button-group';\n", " continue;\n", " }\n", - " var button = $('');\n", - " button.click(method_name, toolbar_event);\n", - " button.mouseover(tooltip, toolbar_mouse_event);\n", - " nav_element.append(button);\n", + " if (buttonGroup.hasChildNodes()) {\n", + " toolbar.appendChild(buttonGroup);\n", " }\n", "\n", " // Add the status bar.\n", - " var status_bar = $('');\n", - " nav_element.append(status_bar);\n", - " this.message = status_bar[0];\n", + " var status_bar = document.createElement('span');\n", + " status_bar.classList = 'mpl-message pull-right';\n", + " toolbar.appendChild(status_bar);\n", + " this.message = status_bar;\n", "\n", " // Add the close button to the window.\n", - " var buttongrp = $('
');\n", - " var button = $('');\n", - " button.click(function (evt) { fig.handle_close(fig, {}); } );\n", - " button.mouseover('Stop Interaction', toolbar_mouse_event);\n", - " buttongrp.append(button);\n", - " var titlebar = this.root.find($('.ui-dialog-titlebar'));\n", - " titlebar.prepend(buttongrp);\n", - "}\n", - "\n", - "mpl.figure.prototype._root_extra_style = function(el){\n", - " var fig = this\n", - " el.on(\"remove\", function(){\n", - "\tfig.close_ws(fig, {});\n", + " var buttongrp = document.createElement('div');\n", + " buttongrp.classList = 'btn-group inline pull-right';\n", + " button = document.createElement('button');\n", + " button.classList = 'btn btn-mini btn-primary';\n", + " button.href = '#';\n", + " button.title = 'Stop Interaction';\n", + " button.innerHTML = '';\n", + " button.addEventListener('click', function (_evt) {\n", + " fig.handle_close(fig, {});\n", " });\n", - "}\n", + " button.addEventListener(\n", + " 'mouseover',\n", + " on_mouseover_closure('Stop Interaction')\n", + " );\n", + " buttongrp.appendChild(button);\n", + " var titlebar = this.root.querySelector('.ui-dialog-titlebar');\n", + " titlebar.insertBefore(buttongrp, titlebar.firstChild);\n", + "};\n", + "\n", + "mpl.figure.prototype._remove_fig_handler = function (event) {\n", + " var fig = event.data.fig;\n", + " if (event.target !== this) {\n", + " // Ignore bubbled events from children.\n", + " return;\n", + " }\n", + " fig.close_ws(fig, {});\n", + "};\n", + "\n", + "mpl.figure.prototype._root_extra_style = function (el) {\n", + " el.style.boxSizing = 'content-box'; // override notebook setting of border-box.\n", + "};\n", "\n", - "mpl.figure.prototype._canvas_extra_style = function(el){\n", + "mpl.figure.prototype._canvas_extra_style = function (el) {\n", " // this is important to make the div 'focusable\n", - " el.attr('tabindex', 0)\n", + " el.setAttribute('tabindex', 0);\n", " // reach out to IPython and tell the keyboard manager to turn it's self\n", " // off when our div gets focus\n", "\n", " // location in version 3\n", " if (IPython.notebook.keyboard_manager) {\n", " IPython.notebook.keyboard_manager.register_events(el);\n", - " }\n", - " else {\n", + " } else {\n", " // location in version 2\n", " IPython.keyboard_manager.register_events(el);\n", " }\n", + "};\n", "\n", - "}\n", - "\n", - "mpl.figure.prototype._key_event_extra = function(event, name) {\n", - " var manager = IPython.notebook.keyboard_manager;\n", - " if (!manager)\n", - " manager = IPython.keyboard_manager;\n", - "\n", + "mpl.figure.prototype._key_event_extra = function (event, _name) {\n", " // Check for shift+enter\n", - " if (event.shiftKey && event.which == 13) {\n", + " if (event.shiftKey && event.which === 13) {\n", " this.canvas_div.blur();\n", - " event.shiftKey = false;\n", - " // Send a \"J\" for go to next cell\n", - " event.which = 74;\n", - " event.keyCode = 74;\n", - " manager.command_mode();\n", - " manager.handle_keydown(event);\n", + " // select the cell after this one\n", + " var index = IPython.notebook.find_cell_index(this.cell_info[0]);\n", + " IPython.notebook.select(index + 1);\n", " }\n", - "}\n", + "};\n", "\n", - "mpl.figure.prototype.handle_save = function(fig, msg) {\n", + "mpl.figure.prototype.handle_save = function (fig, _msg) {\n", " fig.ondownload(fig, null);\n", - "}\n", - "\n", + "};\n", "\n", - "mpl.find_output_cell = function(html_output) {\n", + "mpl.find_output_cell = function (html_output) {\n", " // Return the cell and output element which can be found *uniquely* in the notebook.\n", " // Note - this is a bit hacky, but it is done because the \"notebook_saving.Notebook\"\n", " // IPython event is triggered only after the cells have been serialised, which for\n", " // our purposes (turning an active figure into a static one), is too late.\n", " var cells = IPython.notebook.get_cells();\n", " var ncells = cells.length;\n", - " for (var i=0; i= 3 moved mimebundle to data attribute of output\n", " data = data.data;\n", " }\n", - " if (data['text/html'] == html_output) {\n", + " if (data['text/html'] === html_output) {\n", " return [cell, data, j];\n", " }\n", " }\n", " }\n", " }\n", - "}\n", + "};\n", "\n", "// Register the function which deals with the matplotlib target/channel.\n", "// The kernel may be null if the page has been refreshed.\n", - "if (IPython.notebook.kernel != null) {\n", - " IPython.notebook.kernel.comm_manager.register_target('matplotlib', mpl.mpl_figure_comm);\n", + "if (IPython.notebook.kernel !== null) {\n", + " IPython.notebook.kernel.comm_manager.register_target(\n", + " 'matplotlib',\n", + " mpl.mpl_figure_comm\n", + " );\n", "}\n" ], "text/plain": [ @@ -8382,7 +9207,7 @@ { "data": { "text/html": [ - "" + "" ], "text/plain": [ "" @@ -8393,11 +9218,12 @@ } ], "source": [ - "ct.config.bode_feature_periphery_decade = 1.\n", + "ct.config.defaults['bode_feature_periphery_decades'] = 1\n", "ct.config.bode_number_of_samples = 1000\n", "fig = plt.figure()\n", - "out = ct.bode_plot([pt1_w001hzi, pt1_w001hzis, pt2_w001hz, pt2_w001hzs, pt5hz, pt5s, pt5sh], Hz=True,\n", - " omega_limits=(1.,1000.))" + "out = ct.bode_plot(\n", + " [pt1_w001hzi, pt1_w001hzis, pt2_w001hz, pt2_w001hzs, pt5hz, pt5s, pt5sh], \n", + " Hz=True, omega_limits=(1.,1000.))" ] }, { @@ -8409,36 +9235,38 @@ "data": { "application/javascript": [ "/* Put everything inside the global mpl namespace */\n", + "/* global mpl */\n", "window.mpl = {};\n", "\n", - "\n", - "mpl.get_websocket_type = function() {\n", - " if (typeof(WebSocket) !== 'undefined') {\n", + "mpl.get_websocket_type = function () {\n", + " if (typeof WebSocket !== 'undefined') {\n", " return WebSocket;\n", - " } else if (typeof(MozWebSocket) !== 'undefined') {\n", + " } else if (typeof MozWebSocket !== 'undefined') {\n", " return MozWebSocket;\n", " } else {\n", - " alert('Your browser does not have WebSocket support.' +\n", - " 'Please try Chrome, Safari or Firefox ≥ 6. ' +\n", - " 'Firefox 4 and 5 are also supported but you ' +\n", - " 'have to enable WebSockets in about:config.');\n", - " };\n", - "}\n", + " alert(\n", + " 'Your browser does not have WebSocket support. ' +\n", + " 'Please try Chrome, Safari or Firefox ≥ 6. ' +\n", + " 'Firefox 4 and 5 are also supported but you ' +\n", + " 'have to enable WebSockets in about:config.'\n", + " );\n", + " }\n", + "};\n", "\n", - "mpl.figure = function(figure_id, websocket, ondownload, parent_element) {\n", + "mpl.figure = function (figure_id, websocket, ondownload, parent_element) {\n", " this.id = figure_id;\n", "\n", " this.ws = websocket;\n", "\n", - " this.supports_binary = (this.ws.binaryType != undefined);\n", + " this.supports_binary = this.ws.binaryType !== undefined;\n", "\n", " if (!this.supports_binary) {\n", - " var warnings = document.getElementById(\"mpl-warnings\");\n", + " var warnings = document.getElementById('mpl-warnings');\n", " if (warnings) {\n", " warnings.style.display = 'block';\n", - " warnings.textContent = (\n", - " \"This browser does not support binary websocket messages. \" +\n", - " \"Performance may be slow.\");\n", + " warnings.textContent =\n", + " 'This browser does not support binary websocket messages. ' +\n", + " 'Performance may be slow.';\n", " }\n", " }\n", "\n", @@ -8453,11 +9281,11 @@ "\n", " this.image_mode = 'full';\n", "\n", - " this.root = $('
');\n", - " this._root_extra_style(this.root)\n", - " this.root.attr('style', 'display: inline-block');\n", + " this.root = document.createElement('div');\n", + " this.root.setAttribute('style', 'display: inline-block');\n", + " this._root_extra_style(this.root);\n", "\n", - " $(parent_element).append(this.root);\n", + " parent_element.appendChild(this.root);\n", "\n", " this._init_header(this);\n", " this._init_canvas(this);\n", @@ -8467,285 +9295,366 @@ "\n", " this.waiting = false;\n", "\n", - " this.ws.onopen = function () {\n", - " fig.send_message(\"supports_binary\", {value: fig.supports_binary});\n", - " fig.send_message(\"send_image_mode\", {});\n", - " if (mpl.ratio != 1) {\n", - " fig.send_message(\"set_dpi_ratio\", {'dpi_ratio': mpl.ratio});\n", - " }\n", - " fig.send_message(\"refresh\", {});\n", + " this.ws.onopen = function () {\n", + " fig.send_message('supports_binary', { value: fig.supports_binary });\n", + " fig.send_message('send_image_mode', {});\n", + " if (fig.ratio !== 1) {\n", + " fig.send_message('set_device_pixel_ratio', {\n", + " device_pixel_ratio: fig.ratio,\n", + " });\n", " }\n", + " fig.send_message('refresh', {});\n", + " };\n", "\n", - " this.imageObj.onload = function() {\n", - " if (fig.image_mode == 'full') {\n", - " // Full images could contain transparency (where diff images\n", - " // almost always do), so we need to clear the canvas so that\n", - " // there is no ghosting.\n", - " fig.context.clearRect(0, 0, fig.canvas.width, fig.canvas.height);\n", - " }\n", - " fig.context.drawImage(fig.imageObj, 0, 0);\n", - " };\n", + " this.imageObj.onload = function () {\n", + " if (fig.image_mode === 'full') {\n", + " // Full images could contain transparency (where diff images\n", + " // almost always do), so we need to clear the canvas so that\n", + " // there is no ghosting.\n", + " fig.context.clearRect(0, 0, fig.canvas.width, fig.canvas.height);\n", + " }\n", + " fig.context.drawImage(fig.imageObj, 0, 0);\n", + " };\n", "\n", - " this.imageObj.onunload = function() {\n", + " this.imageObj.onunload = function () {\n", " fig.ws.close();\n", - " }\n", + " };\n", "\n", " this.ws.onmessage = this._make_on_message_function(this);\n", "\n", " this.ondownload = ondownload;\n", - "}\n", - "\n", - "mpl.figure.prototype._init_header = function() {\n", - " var titlebar = $(\n", - " '
');\n", - " var titletext = $(\n", - " '
');\n", - " titlebar.append(titletext)\n", - " this.root.append(titlebar);\n", - " this.header = titletext[0];\n", - "}\n", - "\n", - "\n", - "\n", - "mpl.figure.prototype._canvas_extra_style = function(canvas_div) {\n", - "\n", - "}\n", + "};\n", "\n", + "mpl.figure.prototype._init_header = function () {\n", + " var titlebar = document.createElement('div');\n", + " titlebar.classList =\n", + " 'ui-dialog-titlebar ui-widget-header ui-corner-all ui-helper-clearfix';\n", + " var titletext = document.createElement('div');\n", + " titletext.classList = 'ui-dialog-title';\n", + " titletext.setAttribute(\n", + " 'style',\n", + " 'width: 100%; text-align: center; padding: 3px;'\n", + " );\n", + " titlebar.appendChild(titletext);\n", + " this.root.appendChild(titlebar);\n", + " this.header = titletext;\n", + "};\n", "\n", - "mpl.figure.prototype._root_extra_style = function(canvas_div) {\n", + "mpl.figure.prototype._canvas_extra_style = function (_canvas_div) {};\n", "\n", - "}\n", + "mpl.figure.prototype._root_extra_style = function (_canvas_div) {};\n", "\n", - "mpl.figure.prototype._init_canvas = function() {\n", + "mpl.figure.prototype._init_canvas = function () {\n", " var fig = this;\n", "\n", - " var canvas_div = $('
');\n", - "\n", - " canvas_div.attr('style', 'position: relative; clear: both; outline: 0');\n", + " var canvas_div = (this.canvas_div = document.createElement('div'));\n", + " canvas_div.setAttribute(\n", + " 'style',\n", + " 'border: 1px solid #ddd;' +\n", + " 'box-sizing: content-box;' +\n", + " 'clear: both;' +\n", + " 'min-height: 1px;' +\n", + " 'min-width: 1px;' +\n", + " 'outline: 0;' +\n", + " 'overflow: hidden;' +\n", + " 'position: relative;' +\n", + " 'resize: both;'\n", + " );\n", "\n", - " function canvas_keyboard_event(event) {\n", - " return fig.key_event(event, event['data']);\n", + " function on_keyboard_event_closure(name) {\n", + " return function (event) {\n", + " return fig.key_event(event, name);\n", + " };\n", " }\n", "\n", - " canvas_div.keydown('key_press', canvas_keyboard_event);\n", - " canvas_div.keyup('key_release', canvas_keyboard_event);\n", - " this.canvas_div = canvas_div\n", - " this._canvas_extra_style(canvas_div)\n", - " this.root.append(canvas_div);\n", + " canvas_div.addEventListener(\n", + " 'keydown',\n", + " on_keyboard_event_closure('key_press')\n", + " );\n", + " canvas_div.addEventListener(\n", + " 'keyup',\n", + " on_keyboard_event_closure('key_release')\n", + " );\n", + "\n", + " this._canvas_extra_style(canvas_div);\n", + " this.root.appendChild(canvas_div);\n", + "\n", + " var canvas = (this.canvas = document.createElement('canvas'));\n", + " canvas.classList.add('mpl-canvas');\n", + " canvas.setAttribute('style', 'box-sizing: content-box;');\n", "\n", - " var canvas = $('');\n", - " canvas.addClass('mpl-canvas');\n", - " canvas.attr('style', \"left: 0; top: 0; z-index: 0; outline: 0\")\n", + " this.context = canvas.getContext('2d');\n", "\n", - " this.canvas = canvas[0];\n", - " this.context = canvas[0].getContext(\"2d\");\n", + " var backingStore =\n", + " this.context.backingStorePixelRatio ||\n", + " this.context.webkitBackingStorePixelRatio ||\n", + " this.context.mozBackingStorePixelRatio ||\n", + " this.context.msBackingStorePixelRatio ||\n", + " this.context.oBackingStorePixelRatio ||\n", + " this.context.backingStorePixelRatio ||\n", + " 1;\n", "\n", - " var backingStore = this.context.backingStorePixelRatio ||\n", - "\tthis.context.webkitBackingStorePixelRatio ||\n", - "\tthis.context.mozBackingStorePixelRatio ||\n", - "\tthis.context.msBackingStorePixelRatio ||\n", - "\tthis.context.oBackingStorePixelRatio ||\n", - "\tthis.context.backingStorePixelRatio || 1;\n", + " this.ratio = (window.devicePixelRatio || 1) / backingStore;\n", "\n", - " mpl.ratio = (window.devicePixelRatio || 1) / backingStore;\n", + " var rubberband_canvas = (this.rubberband_canvas = document.createElement(\n", + " 'canvas'\n", + " ));\n", + " rubberband_canvas.setAttribute(\n", + " 'style',\n", + " 'box-sizing: content-box; position: absolute; left: 0; top: 0; z-index: 1;'\n", + " );\n", "\n", - " var rubberband = $('');\n", - " rubberband.attr('style', \"position: absolute; left: 0; top: 0; z-index: 1;\")\n", + " // Apply a ponyfill if ResizeObserver is not implemented by browser.\n", + " if (this.ResizeObserver === undefined) {\n", + " if (window.ResizeObserver !== undefined) {\n", + " this.ResizeObserver = window.ResizeObserver;\n", + " } else {\n", + " var obs = _JSXTOOLS_RESIZE_OBSERVER({});\n", + " this.ResizeObserver = obs.ResizeObserver;\n", + " }\n", + " }\n", "\n", - " var pass_mouse_events = true;\n", + " this.resizeObserverInstance = new this.ResizeObserver(function (entries) {\n", + " var nentries = entries.length;\n", + " for (var i = 0; i < nentries; i++) {\n", + " var entry = entries[i];\n", + " var width, height;\n", + " if (entry.contentBoxSize) {\n", + " if (entry.contentBoxSize instanceof Array) {\n", + " // Chrome 84 implements new version of spec.\n", + " width = entry.contentBoxSize[0].inlineSize;\n", + " height = entry.contentBoxSize[0].blockSize;\n", + " } else {\n", + " // Firefox implements old version of spec.\n", + " width = entry.contentBoxSize.inlineSize;\n", + " height = entry.contentBoxSize.blockSize;\n", + " }\n", + " } else {\n", + " // Chrome <84 implements even older version of spec.\n", + " width = entry.contentRect.width;\n", + " height = entry.contentRect.height;\n", + " }\n", "\n", - " canvas_div.resizable({\n", - " start: function(event, ui) {\n", - " pass_mouse_events = false;\n", - " },\n", - " resize: function(event, ui) {\n", - " fig.request_resize(ui.size.width, ui.size.height);\n", - " },\n", - " stop: function(event, ui) {\n", - " pass_mouse_events = true;\n", - " fig.request_resize(ui.size.width, ui.size.height);\n", - " },\n", + " // Keep the size of the canvas and rubber band canvas in sync with\n", + " // the canvas container.\n", + " if (entry.devicePixelContentBoxSize) {\n", + " // Chrome 84 implements new version of spec.\n", + " canvas.setAttribute(\n", + " 'width',\n", + " entry.devicePixelContentBoxSize[0].inlineSize\n", + " );\n", + " canvas.setAttribute(\n", + " 'height',\n", + " entry.devicePixelContentBoxSize[0].blockSize\n", + " );\n", + " } else {\n", + " canvas.setAttribute('width', width * fig.ratio);\n", + " canvas.setAttribute('height', height * fig.ratio);\n", + " }\n", + " canvas.setAttribute(\n", + " 'style',\n", + " 'width: ' + width + 'px; height: ' + height + 'px;'\n", + " );\n", + "\n", + " rubberband_canvas.setAttribute('width', width);\n", + " rubberband_canvas.setAttribute('height', height);\n", + "\n", + " // And update the size in Python. We ignore the initial 0/0 size\n", + " // that occurs as the element is placed into the DOM, which should\n", + " // otherwise not happen due to the minimum size styling.\n", + " if (fig.ws.readyState == 1 && width != 0 && height != 0) {\n", + " fig.request_resize(width, height);\n", + " }\n", + " }\n", " });\n", + " this.resizeObserverInstance.observe(canvas_div);\n", "\n", - " function mouse_event_fn(event) {\n", - " if (pass_mouse_events)\n", - " return fig.mouse_event(event, event['data']);\n", + " function on_mouse_event_closure(name) {\n", + " return function (event) {\n", + " return fig.mouse_event(event, name);\n", + " };\n", " }\n", "\n", - " rubberband.mousedown('button_press', mouse_event_fn);\n", - " rubberband.mouseup('button_release', mouse_event_fn);\n", + " rubberband_canvas.addEventListener(\n", + " 'mousedown',\n", + " on_mouse_event_closure('button_press')\n", + " );\n", + " rubberband_canvas.addEventListener(\n", + " 'mouseup',\n", + " on_mouse_event_closure('button_release')\n", + " );\n", + " rubberband_canvas.addEventListener(\n", + " 'dblclick',\n", + " on_mouse_event_closure('dblclick')\n", + " );\n", " // Throttle sequential mouse events to 1 every 20ms.\n", - " rubberband.mousemove('motion_notify', mouse_event_fn);\n", + " rubberband_canvas.addEventListener(\n", + " 'mousemove',\n", + " on_mouse_event_closure('motion_notify')\n", + " );\n", "\n", - " rubberband.mouseenter('figure_enter', mouse_event_fn);\n", - " rubberband.mouseleave('figure_leave', mouse_event_fn);\n", + " rubberband_canvas.addEventListener(\n", + " 'mouseenter',\n", + " on_mouse_event_closure('figure_enter')\n", + " );\n", + " rubberband_canvas.addEventListener(\n", + " 'mouseleave',\n", + " on_mouse_event_closure('figure_leave')\n", + " );\n", "\n", - " canvas_div.on(\"wheel\", function (event) {\n", - " event = event.originalEvent;\n", - " event['data'] = 'scroll'\n", + " canvas_div.addEventListener('wheel', function (event) {\n", " if (event.deltaY < 0) {\n", " event.step = 1;\n", " } else {\n", " event.step = -1;\n", " }\n", - " mouse_event_fn(event);\n", + " on_mouse_event_closure('scroll')(event);\n", " });\n", "\n", - " canvas_div.append(canvas);\n", - " canvas_div.append(rubberband);\n", - "\n", - " this.rubberband = rubberband;\n", - " this.rubberband_canvas = rubberband[0];\n", - " this.rubberband_context = rubberband[0].getContext(\"2d\");\n", - " this.rubberband_context.strokeStyle = \"#000000\";\n", - "\n", - " this._resize_canvas = function(width, height) {\n", - " // Keep the size of the canvas, canvas container, and rubber band\n", - " // canvas in synch.\n", - " canvas_div.css('width', width)\n", - " canvas_div.css('height', height)\n", + " canvas_div.appendChild(canvas);\n", + " canvas_div.appendChild(rubberband_canvas);\n", "\n", - " canvas.attr('width', width * mpl.ratio);\n", - " canvas.attr('height', height * mpl.ratio);\n", - " canvas.attr('style', 'width: ' + width + 'px; height: ' + height + 'px;');\n", + " this.rubberband_context = rubberband_canvas.getContext('2d');\n", + " this.rubberband_context.strokeStyle = '#000000';\n", "\n", - " rubberband.attr('width', width);\n", - " rubberband.attr('height', height);\n", - " }\n", - "\n", - " // Set the figure to an initial 600x600px, this will subsequently be updated\n", - " // upon first draw.\n", - " this._resize_canvas(600, 600);\n", + " this._resize_canvas = function (width, height, forward) {\n", + " if (forward) {\n", + " canvas_div.style.width = width + 'px';\n", + " canvas_div.style.height = height + 'px';\n", + " }\n", + " };\n", "\n", " // Disable right mouse context menu.\n", - " $(this.rubberband_canvas).bind(\"contextmenu\",function(e){\n", + " this.rubberband_canvas.addEventListener('contextmenu', function (_e) {\n", + " event.preventDefault();\n", " return false;\n", " });\n", "\n", - " function set_focus () {\n", + " function set_focus() {\n", " canvas.focus();\n", " canvas_div.focus();\n", " }\n", "\n", " window.setTimeout(set_focus, 100);\n", - "}\n", + "};\n", "\n", - "mpl.figure.prototype._init_toolbar = function() {\n", + "mpl.figure.prototype._init_toolbar = function () {\n", " var fig = this;\n", "\n", - " var nav_element = $('
')\n", - " nav_element.attr('style', 'width: 100%');\n", - " this.root.append(nav_element);\n", + " var toolbar = document.createElement('div');\n", + " toolbar.classList = 'mpl-toolbar';\n", + " this.root.appendChild(toolbar);\n", "\n", - " // Define a callback function for later on.\n", - " function toolbar_event(event) {\n", - " return fig.toolbar_button_onclick(event['data']);\n", + " function on_click_closure(name) {\n", + " return function (_event) {\n", + " return fig.toolbar_button_onclick(name);\n", + " };\n", " }\n", - " function toolbar_mouse_event(event) {\n", - " return fig.toolbar_button_onmouseover(event['data']);\n", + "\n", + " function on_mouseover_closure(tooltip) {\n", + " return function (event) {\n", + " if (!event.currentTarget.disabled) {\n", + " return fig.toolbar_button_onmouseover(tooltip);\n", + " }\n", + " };\n", " }\n", "\n", - " for(var toolbar_ind in mpl.toolbar_items) {\n", + " fig.buttons = {};\n", + " var buttonGroup = document.createElement('div');\n", + " buttonGroup.classList = 'mpl-button-group';\n", + " for (var toolbar_ind in mpl.toolbar_items) {\n", " var name = mpl.toolbar_items[toolbar_ind][0];\n", " var tooltip = mpl.toolbar_items[toolbar_ind][1];\n", " var image = mpl.toolbar_items[toolbar_ind][2];\n", " var method_name = mpl.toolbar_items[toolbar_ind][3];\n", "\n", " if (!name) {\n", - " // put a spacer in here.\n", + " /* Instead of a spacer, we start a new button group. */\n", + " if (buttonGroup.hasChildNodes()) {\n", + " toolbar.appendChild(buttonGroup);\n", + " }\n", + " buttonGroup = document.createElement('div');\n", + " buttonGroup.classList = 'mpl-button-group';\n", " continue;\n", " }\n", - " var button = $('');\n", - " button.click(method_name, toolbar_event);\n", - " button.mouseover(tooltip, toolbar_mouse_event);\n", - " nav_element.append(button);\n", + " if (buttonGroup.hasChildNodes()) {\n", + " toolbar.appendChild(buttonGroup);\n", " }\n", "\n", " // Add the status bar.\n", - " var status_bar = $('');\n", - " nav_element.append(status_bar);\n", - " this.message = status_bar[0];\n", + " var status_bar = document.createElement('span');\n", + " status_bar.classList = 'mpl-message pull-right';\n", + " toolbar.appendChild(status_bar);\n", + " this.message = status_bar;\n", "\n", " // Add the close button to the window.\n", - " var buttongrp = $('
');\n", - " var button = $('');\n", - " button.click(function (evt) { fig.handle_close(fig, {}); } );\n", - " button.mouseover('Stop Interaction', toolbar_mouse_event);\n", - " buttongrp.append(button);\n", - " var titlebar = this.root.find($('.ui-dialog-titlebar'));\n", - " titlebar.prepend(buttongrp);\n", - "}\n", - "\n", - "mpl.figure.prototype._root_extra_style = function(el){\n", - " var fig = this\n", - " el.on(\"remove\", function(){\n", - "\tfig.close_ws(fig, {});\n", + " var buttongrp = document.createElement('div');\n", + " buttongrp.classList = 'btn-group inline pull-right';\n", + " button = document.createElement('button');\n", + " button.classList = 'btn btn-mini btn-primary';\n", + " button.href = '#';\n", + " button.title = 'Stop Interaction';\n", + " button.innerHTML = '';\n", + " button.addEventListener('click', function (_evt) {\n", + " fig.handle_close(fig, {});\n", " });\n", - "}\n", + " button.addEventListener(\n", + " 'mouseover',\n", + " on_mouseover_closure('Stop Interaction')\n", + " );\n", + " buttongrp.appendChild(button);\n", + " var titlebar = this.root.querySelector('.ui-dialog-titlebar');\n", + " titlebar.insertBefore(buttongrp, titlebar.firstChild);\n", + "};\n", + "\n", + "mpl.figure.prototype._remove_fig_handler = function (event) {\n", + " var fig = event.data.fig;\n", + " if (event.target !== this) {\n", + " // Ignore bubbled events from children.\n", + " return;\n", + " }\n", + " fig.close_ws(fig, {});\n", + "};\n", "\n", - "mpl.figure.prototype._canvas_extra_style = function(el){\n", + "mpl.figure.prototype._root_extra_style = function (el) {\n", + " el.style.boxSizing = 'content-box'; // override notebook setting of border-box.\n", + "};\n", + "\n", + "mpl.figure.prototype._canvas_extra_style = function (el) {\n", " // this is important to make the div 'focusable\n", - " el.attr('tabindex', 0)\n", + " el.setAttribute('tabindex', 0);\n", " // reach out to IPython and tell the keyboard manager to turn it's self\n", " // off when our div gets focus\n", "\n", " // location in version 3\n", " if (IPython.notebook.keyboard_manager) {\n", " IPython.notebook.keyboard_manager.register_events(el);\n", - " }\n", - " else {\n", + " } else {\n", " // location in version 2\n", " IPython.keyboard_manager.register_events(el);\n", " }\n", + "};\n", "\n", - "}\n", - "\n", - "mpl.figure.prototype._key_event_extra = function(event, name) {\n", - " var manager = IPython.notebook.keyboard_manager;\n", - " if (!manager)\n", - " manager = IPython.keyboard_manager;\n", - "\n", + "mpl.figure.prototype._key_event_extra = function (event, _name) {\n", " // Check for shift+enter\n", - " if (event.shiftKey && event.which == 13) {\n", + " if (event.shiftKey && event.which === 13) {\n", " this.canvas_div.blur();\n", - " event.shiftKey = false;\n", - " // Send a \"J\" for go to next cell\n", - " event.which = 74;\n", - " event.keyCode = 74;\n", - " manager.command_mode();\n", - " manager.handle_keydown(event);\n", + " // select the cell after this one\n", + " var index = IPython.notebook.find_cell_index(this.cell_info[0]);\n", + " IPython.notebook.select(index + 1);\n", " }\n", - "}\n", + "};\n", "\n", - "mpl.figure.prototype.handle_save = function(fig, msg) {\n", + "mpl.figure.prototype.handle_save = function (fig, _msg) {\n", " fig.ondownload(fig, null);\n", - "}\n", - "\n", + "};\n", "\n", - "mpl.find_output_cell = function(html_output) {\n", + "mpl.find_output_cell = function (html_output) {\n", " // Return the cell and output element which can be found *uniquely* in the notebook.\n", " // Note - this is a bit hacky, but it is done because the \"notebook_saving.Notebook\"\n", " // IPython event is triggered only after the cells have been serialised, which for\n", " // our purposes (turning an active figure into a static one), is too late.\n", " var cells = IPython.notebook.get_cells();\n", " var ncells = cells.length;\n", - " for (var i=0; i= 3 moved mimebundle to data attribute of output\n", " data = data.data;\n", " }\n", - " if (data['text/html'] == html_output) {\n", + " if (data['text/html'] === html_output) {\n", " return [cell, data, j];\n", " }\n", " }\n", " }\n", " }\n", - "}\n", + "};\n", "\n", "// Register the function which deals with the matplotlib target/channel.\n", "// The kernel may be null if the page has been refreshed.\n", - "if (IPython.notebook.kernel != null) {\n", - " IPython.notebook.kernel.comm_manager.register_target('matplotlib', mpl.mpl_figure_comm);\n", + "if (IPython.notebook.kernel !== null) {\n", + " IPython.notebook.kernel.comm_manager.register_target(\n", + " 'matplotlib',\n", + " mpl.mpl_figure_comm\n", + " );\n", "}\n" ], "text/plain": [ @@ -9999,7 +11196,7 @@ { "data": { "text/html": [ - "" + "" ], "text/plain": [ "" @@ -10011,7 +11208,7 @@ ], "source": [ "fig = plt.figure()\n", - "ct.nyquist_plot([pt1_w001hzis, pt2_w001hz])" + "ct.nyquist_plot([pt1_w001hzis, pt2_w001hz]);" ] }, { @@ -10024,7 +11221,7 @@ ], "metadata": { "kernelspec": { - "display_name": "Python 3", + "display_name": "Python 3 (ipykernel)", "language": "python", "name": "python3" }, @@ -10038,7 +11235,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.7.3" + "version": "3.10.6" } }, "nbformat": 4, From f865c8cb2f64be2b063ff35cef7a7e5bc98eca2d Mon Sep 17 00:00:00 2001 From: Richard Murray Date: Fri, 21 Jul 2023 19:41:30 -0700 Subject: [PATCH 13/17] updated docstrings, userdocs + fixes along the way --- control/descfcn.py | 98 ++++++++++---- control/frdata.py | 29 ++++- control/freqplot.py | 188 +++++++++++++++++---------- control/lti.py | 40 ++++-- control/matlab/wrappers.py | 6 +- control/tests/descfcn_test.py | 5 + control/tests/freqplot_test.py | 21 ++- control/tests/kwargs_test.py | 14 +- control/tests/nyquist_test.py | 4 +- control/timeplot.py | 4 +- doc/Makefile | 8 +- doc/classes.rst | 2 + doc/descfcn.rst | 15 ++- doc/freqplot-gangof4.png | Bin 0 -> 41695 bytes doc/freqplot-mimo_bode-default.png | Bin 0 -> 53147 bytes doc/freqplot-mimo_bode-magonly.png | Bin 0 -> 48186 bytes doc/freqplot-mimo_svplot-default.png | Bin 0 -> 32370 bytes doc/freqplot-siso_bode-default.png | Bin 0 -> 46705 bytes doc/plotting.rst | 169 +++++++++++++++++++++--- examples/mrac_siso_lyapunov.py | 5 +- examples/mrac_siso_mit.py | 6 +- examples/pvtol-nested-ss.py | 47 ++----- 22 files changed, 475 insertions(+), 186 deletions(-) create mode 100644 doc/freqplot-gangof4.png create mode 100644 doc/freqplot-mimo_bode-default.png create mode 100644 doc/freqplot-mimo_bode-magonly.png create mode 100644 doc/freqplot-mimo_svplot-default.png create mode 100644 doc/freqplot-siso_bode-default.png diff --git a/control/descfcn.py b/control/descfcn.py index 505d716d5..6586e6f20 100644 --- a/control/descfcn.py +++ b/control/descfcn.py @@ -22,9 +22,9 @@ from . import config __all__ = ['describing_function', 'describing_function_plot', - 'describing_function_response', 'DescribingFunctionNonlinearity', - 'friction_backlash_nonlinearity', 'relay_hysteresis_nonlinearity', - 'saturation_nonlinearity'] + 'describing_function_response', 'DescribingFunctionResponse', + 'DescribingFunctionNonlinearity', 'friction_backlash_nonlinearity', + 'relay_hysteresis_nonlinearity', 'saturation_nonlinearity'] # Class for nonlinearities with a built-in describing function class DescribingFunctionNonlinearity(): @@ -213,13 +213,46 @@ def describing_function( # Simple class to store the describing function response class DescribingFunctionResponse: + """Results of describing function analysis. + + Describing functions allow analysis of a linear I/O systems with a + static nonlinear feedback function. The DescribingFunctionResponse + class is used by the :func:`~control.describing_function_response` + function to return the results of a describing function analysis. The + response object can be used to obtain information about the describing + function analysis or generate a Nyquist plot showing the frequency + response of the linear systems and the describing function for the + nonlinear element. + + Attributes + ---------- + response : :class:`~control.FrequencyResponseData` + Frequency response of the linear system component of the system. + intersections : 1D array of 2-tuples or None + A list of all amplitudes and frequencies in which + :math:`H(j\\omega) N(a) = -1`, where :math:`N(a)` is the describing + function associated with `F`, or `None` if there are no such + points. Each pair represents a potential limit cycle for the + closed loop system with amplitude given by the first value of the + tuple and frequency given by the second value. + N_vals : complex array + Complex value of the describing function. + positions : list of complex + Location of the intersections in the complex plane. + + """ def __init__(self, response, N_vals, positions, intersections): + """Create a describing function response data object.""" self.response = response self.N_vals = N_vals self.positions = positions self.intersections = intersections def plot(self, **kwargs): + """Plot the results of a describing function analysis. + + See :func:`~control.describing_function_plot` for details. + """ return describing_function_plot(self, **kwargs) # Implement iter, getitem, len to allow recovering the intersections @@ -262,21 +295,27 @@ def describing_function_response( Returns ------- - intersections : 1D array of 2-tuples or None - A list of all amplitudes and frequencies in which :math:`H(j\\omega) - N(a) = -1`, where :math:`N(a)` is the describing function associated - with `F`, or `None` if there are no such points. Each pair represents - a potential limit cycle for the closed loop system with amplitude - given by the first value of the tuple and frequency given by the - second value. + response : :class:`~control.DescribingFunctionResponse` object + Response object that contains the result of the describing function + analysis. The following information can be retrieved from this + object: + response.intersections : 1D array of 2-tuples or None + A list of all amplitudes and frequencies in which + :math:`H(j\\omega) N(a) = -1`, where :math:`N(a)` is the describing + function associated with `F`, or `None` if there are no such + points. Each pair represents a potential limit cycle for the + closed loop system with amplitude given by the first value of the + tuple and frequency given by the second value. Examples -------- >>> H_simple = ct.tf([8], [1, 2, 2, 1]) >>> F_saturation = ct.saturation_nonlinearity(1) >>> amp = np.linspace(1, 4, 10) - >>> ct.describing_function_response(H_simple, F_saturation, amp) # doctest: +SKIP + >>> response = ct.describing_function_response(H_simple, F_saturation, amp) + >>> response.intersections # doctest: +SKIP [(3.343844998258643, 1.4142293090899216)] + >>> lines = response.plot() """ # Decide whether to turn on warnings or not @@ -340,14 +379,29 @@ def _cost(x): def describing_function_plot( *sysdata, label="%5.2g @ %-5.2g", **kwargs): - """Plot a Nyquist plot with a describing function for a nonlinear system. + """describing_function_plot(data, *args, **kwargs) + + Plot a Nyquist plot with a describing function for a nonlinear system. This function generates a Nyquist plot for a closed loop system consisting of a linear system with a static nonlinear function in the feedback path. + The function may be called in one of two forms: + + describing_function_plot(response[, options]) + + describing_function_plot(H, F, A[, omega[, options]]) + + In the first form, the response should be generated using the + :func:`~control.describing_function_response` function. In the second + form, that function is called internally, with the listed arguments. + Parameters ---------- + data : :class:`~control.DescribingFunctionData` + A describing function response data object created by + :func:`~control.describing_function_response`. H : LTI system Linear time-invariant (LTI) system (state space, transfer function, or FRD) @@ -357,7 +411,9 @@ def describing_function_plot( A : list List of amplitudes to be used for the describing function plot. omega : list, optional - List of frequencies to be used for the linear system Nyquist curve. + List of frequencies to be used for the linear system Nyquist + curve. If not specified (or None), frequencies are computed + automatically based on the properties of the linear system. refine : bool, optional If True (default), refine the location of the intersection of the Nyquist curve for the linear system and the describing function to @@ -368,21 +424,19 @@ def describing_function_plot( Returns ------- - intersections : 1D array of 2-tuples or None - A list of all amplitudes and frequencies in which :math:`H(j\\omega) - N(a) = -1`, where :math:`N(a)` is the describing function associated - with `F`, or `None` if there are no such points. Each pair represents - a potential limit cycle for the closed loop system with amplitude - given by the first value of the tuple and frequency given by the - second value. + lines : 1D array of Line2D + Arrray of Line2D objects for each line in the plot. The first + element of the array is a list of lines (typically only one) for + the Nyquist plot of the linear I/O styem. The second element of + the array is a list of lines (typically only one) for the + describing function curve. Examples -------- >>> H_simple = ct.tf([8], [1, 2, 2, 1]) >>> F_saturation = ct.saturation_nonlinearity(1) >>> amp = np.linspace(1, 4, 10) - >>> ct.describing_function_plot(H_simple, F_saturation, amp) # doctest: +SKIP - [(3.343844998258643, 1.4142293090899216)] + >>> lines = ct.describing_function_plot(H_simple, F_saturation, amp) """ # Process keywords diff --git a/control/frdata.py b/control/frdata.py index 91e6aa683..c677dd7f7 100644 --- a/control/frdata.py +++ b/control/frdata.py @@ -66,7 +66,9 @@ class FrequencyResponseData(LTI): A class for models defined by frequency response data (FRD). The FrequencyResponseData (FRD) class is used to represent systems in - frequency response data form. + frequency response data form. It can be created manually using the + class constructor, using the :func:~~control.frd` factory function + (preferred), or via the :func:`~control.frequency_response` function. Parameters ---------- @@ -654,12 +656,27 @@ def feedback(self, other=1, sign=-1): return FRD(fresp, other.omega, smooth=(self.ifunc is not None)) # Plotting interface - def plot(self, *args, **kwargs): - from .freqplot import bode_plot + def plot(self, plot_type=None, *args, **kwargs): + """Plot the frequency response using a Bode plot. - # For now, only support Bode plots - # TODO: add 'kind' keyword and Nyquist plots (?) - return bode_plot(self, *args, **kwargs) + Plot the frequency response using either a standard Bode plot + (default) or using a singular values plot (by setting `plot_type` + to 'svplot'). See :func:`~control.bode_plot` and + :func:`~control.singular_values_plot` for more detailed + descriptions. + + """ + from .freqplot import bode_plot, singular_values_plot + + if plot_type is None: + plot_type = self.plot_type + + if plot_type == 'bode': + return bode_plot(self, *args, **kwargs) + elif plot_type == 'svplot': + return singular_values_plot(self, *args, **kwargs) + else: + raise ValueError(f"unknown plot type '{plot_type}'") # Convert to pandas def to_pandas(self): diff --git a/control/freqplot.py b/control/freqplot.py index 2025ab71d..89294a40a 100644 --- a/control/freqplot.py +++ b/control/freqplot.py @@ -81,10 +81,10 @@ from .timeplot import _make_legend_labels from . import config -__all__ = ['bode_plot', 'nyquist_response', 'nyquist_plot', - 'singular_values_response', 'singular_values_plot', - 'gangof4_plot', 'gangof4_response', 'bode', 'nyquist', - 'gangof4'] +__all__ = ['bode_plot', 'NyquistResponseData', 'nyquist_response', + 'nyquist_plot', 'singular_values_response', + 'singular_values_plot', 'gangof4_plot', 'gangof4_response', + 'bode', 'nyquist', 'gangof4'] # Default font dictionary _freqplot_rcParams = mpl.rcParams.copy() @@ -132,9 +132,9 @@ def plot(self, *args, plot_type=None, **kwargs): plot_type = response.plot_type if plot_type == 'bode': - bode_plot(self, *args, **kwargs) + return bode_plot(self, *args, **kwargs) elif plot_type == 'svplot': - singular_values_plot(self, *args, **kwargs) + return singular_values_plot(self, *args, **kwargs) else: raise ValueError(f"unknown plot type '{plot_type}'") @@ -155,15 +155,20 @@ def bode_plot( sharex=None, sharey=None, title=None, relabel=True, **kwargs): """Bode plot for a system. - Bode plot of a frequency response over a (optional) frequency range. + Plot the magnitude and phase of the frequency response over a + (optional) frequency range. Parameters ---------- - data : list of `FrequencyResponseData` - List of :class:`FrequencyResponseData` objects. For backward - compatibility, a list of LTI systems can also be given. - omega : array_like - List of frequencies in rad/sec over to plot over. + data : list of `FrequencyResponseData` or `LTI` + List of LTI systems or :class:`FrequencyResponseData` objects. A + single system or frequency response can also be passed. + omega : array_like, optoinal + List of frequencies in rad/sec over to plot over. If not specified, + this will be determined from the proporties of the systems. + *fmt : :func:`matplotlib.pyplot.plot` format string, optional + Passed to `matplotlib` as the format string for all lines in the plot. + The `omega` parameter must be present (use omega=None if needed). dB : bool If True, plot result in dB. Default is False. Hz : bool @@ -179,24 +184,15 @@ def bode_plot( the graph. Setting display_margins turns off the axes grid. margins_method : str, optional Method to use in computing margins (see :func:`stability_margins`). - *fmt : :func:`matplotlib.pyplot.plot` format string, optional - Passed to `matplotlib` as the format string for all lines in the plot. - The `omega` parameter must be present (use omega=None if needed). **kwargs : :func:`matplotlib.pyplot.plot` keyword properties, optional Additional keywords passed to `matplotlib` to specify line properties. Returns ------- - out : array of Line2D + lines : array of Line2D Array of Line2D objects for each line in the plot. The shape of the array matches the subplots shape and the value of the array is a list of Line2D objects in that subplot. - mag : ndarray (or list of ndarray if len(data) > 1)) - If plot=False, magnitude of the response (deprecated). - phase : ndarray (or list of ndarray if len(data) > 1)) - If plot=False, phase in radians of the response (deprecated). - omega : ndarray (or list of ndarray if len(data) > 1)) - If plot=False, frequency in rad/sec (deprecated). Other Parameters ---------------- @@ -235,11 +231,11 @@ def bode_plot( ----- 1. Starting with python-control version 0.10, `bode_plot`returns an array of lines instead of magnitude, phase, and frequency. To - recover the # old behavior, call `bode_plot` with `plot=True`, which - will force the legacy return values to be used (with a warning). To - obtain just the frequency response of a system (or list of systems) - without plotting, use the :func:`~control.frequency_response` - command. + recover the old behavior, call `bode_plot` with `plot=True`, which + will force the legacy values (mag, phase, omega) to be returned + (with a warning). To obtain just the frequency response of a system + (or list of systems) without plotting, use the + :func:`~control.frequency_response` command. 2. If a discrete time model is given, the frequency response is plotted along the upper branch of the unit circle, using the mapping ``z = @@ -1128,6 +1124,36 @@ def gen_zero_centered_series(val_min, val_max, period): class NyquistResponseData: + """Nyquist response data object. + + Nyquist contour analysis allows the stability and robustness of a + closed loop linear system to be evaluated using the open loop response + of the loop transfer function. The NyquistResponseData class is used + by the :func:`~control.nyquist_response` function to return the + response of a linear system along the Nyquist 'D' contour. The + response object can be used to obtain information about the Nyquist + response or to generate a Nyquist plot. + + Attributes + ---------- + count : integer + Number of encirclements of the -1 point by the Nyquist curve for + a system evaluated along the Nyquist contour. + contour : complex array + The Nyquist 'D' contour, with appropriate indendtations to avoid + open loop poles and zeros near/on the imaginary axis. + response : complex array + The value of the linear system under study along the Nyquist contour. + dt : None or float + The system timebase. + sysname : str + The name of the system being analyzed. + return_contour: bool + If true, when the object is accessed as an iterable return two + elements": `count` (number of encirlements) and `contour`. If + false (default), then return only `count`. + + """ def __init__( self, count, contour, response, dt, sysname=None, return_contour=False): @@ -1164,8 +1190,8 @@ def plot(self, *args, **kwargs): def nyquist_response( sysdata, omega=None, plot=None, omega_limits=None, omega_num=None, - label_freq=0, color=None, return_contour=False, check_kwargs=True, - warn_encirclements=True, warn_nyquist=True, **kwargs): + return_contour=False, warn_encirclements=True, warn_nyquist=True, + check_kwargs=True, **kwargs): """Nyquist response for a system. Computes a Nyquist contour for the system over a (optional) frequency @@ -1194,19 +1220,18 @@ def nyquist_response( Number of frequency samples to plot. Defaults to config.defaults['freqplot.number_of_samples']. - return_contour : bool, optional - If 'True', return the contour used to evaluate the Nyquist plot. - Returns ------- - count : int (or list of int if len(syslist) > 1) + responses : list of :class:`~control.NyquistResponseData` + For each system, a Nyquist response data object is returned. If + sysdata is a single system, a single elemeent is returned (not a list). + For each response, the following information is available: + response.count : int Number of encirclements of the point -1 by the Nyquist curve. If multiple systems are given, an array of counts is returned. - - contour : ndarray (or list of ndarray if len(syslist) > 1)), optional - The contour used to create the primary Nyquist curve segment, returned - if `return_contour` is Tue. To obtain the Nyquist curve values, - evaluate system(s) along contour. + response.contour : ndarray + The contour used to create the primary Nyquist curve segment. To + obtain the Nyquist curve values, evaluate system(s) along contour. Other Parameters ---------------- @@ -1259,15 +1284,19 @@ def nyquist_response( primary curve use a dotted line style and the scaled portion of the mirror image use a dashdot line style. + 4. If the legacy keyword `return_contour` is specified as True, the + response object can be iterated over to return `count, contour`. + This behavior is deprecated and will be removed in a future release. + Examples -------- >>> G = ct.zpk([], [-1, -2, -3], gain=100) >>> response = ct.nyquist_response(G) >>> count = response.count - >>> response.plot() + >>> lines = response.plot() """ - # Get values for params (and pop from list to allow keyword use in plot) + # Get values for params omega_num_given = omega_num is not None omega_num = config._get_param('freqplot', 'number_of_samples', omega_num) indent_radius = config._get_param( @@ -1546,16 +1575,16 @@ def nyquist_plot( Returns ------- - out : array of Line2D + lines : array of Line2D 2D array of Line2D objects for each line in the plot. The shape of the array is given by (nsys, 4) where nsys is the number of systems or Nyquist responses passed to the function. The second index specifies the segment type: - 0: unscaled portion of the primary curve - 1: scaled portion of the primary curve - 2: unscaled portion of the mirror curve - 3: scaled portion of the mirror curve + * lines[idx, 0]: unscaled portion of the primary curve + * lines[idx, 1]: scaled portion of the primary curve + * lines[idx, 2]: unscaled portion of the mirror curve + * lines[idx, 3]: scaled portion of the mirror curve Other Parameters ---------------- @@ -1673,6 +1702,21 @@ def nyquist_plot( >>> out = ct.nyquist_plot(G) """ + # + # Keyword processing + # + # Keywords for the nyquist_plot function can either be keywords that + # are unique to this function, keywords that are intended for use by + # nyquist_response (if data is a list of systems), or keywords that + # are intended for the plotting commands. + # + # We first pop off all keywords that are used directly by this + # function. If data is a list of systems, when then pop off keywords + # that correspond to nyquist_response() keywords. The remaining + # keywords are passed to matplotlib (and will generate an error if + # unrecognized). + # + # Get values for params (and pop from list to allow keyword use in plot) arrows = config._get_param( 'nyquist', 'arrows', kwargs, _nyquist_defaults, pop=True) @@ -1726,18 +1770,21 @@ def _parse_linestyle(style_name, allow_false=False): # If argument was a singleton, turn it into a tuple if not isinstance(data, (list, tuple)): - data = (data,) + data = [data] # If we are passed a list of systems, compute response first if all([isinstance( sys, (StateSpace, TransferFunction, FrequencyResponseData)) for sys in data]): + # Get the response, popping off keywords used there nyquist_responses = nyquist_response( - data, omega=omega, check_kwargs=False, **kwargs) - if not isinstance(nyquist_responses, list): - nyquist_responses = [nyquist_responses] - else: - nyquist_responses = data + data, omega=omega, return_contour=return_contour, + omega_limits=kwargs.pop('omega_limits', None), + omega_num=kwargs.pop('omega_num', None), + warn_encirclements=kwargs.pop('warn_encirclements', True), + warn_nyquist=kwargs.pop('warn_nyquist', True), + check_kwargs=False, **kwargs) + else: nyquist_responses = data # Legacy return value processing if plot is not None or return_contour is not None: @@ -1750,6 +1797,10 @@ def _parse_linestyle(style_name, allow_false=False): contours = [response.contour for response in nyquist_responses] if plot is False: + # Make sure we used all of the keywrods + if kwargs: + raise TypeError("unrecognized keywords: ", str(kwargs)) + if len(data) == 1: counts, contours = counts[0], contours[0] @@ -2080,7 +2131,8 @@ def gangof4_response(P, C, omega=None, Hz=False): -------- >>> P = ct.tf([1], [1, 1]) >>> C = ct.tf([2], [1]) - >>> ct.gangof4_plot(P, C) + >>> response = ct.gangof4_response(P, C) + >>> lines = response.plot() """ if not P.issiso() or not C.issiso(): @@ -2140,17 +2192,15 @@ def singular_values_response( List of linear systems (single system is OK). omega : array_like List of frequencies in rad/sec to be used for frequency response. - plot : bool - If True (default), generate the singular values plot. omega_limits : array_like of two values - Limits of the frequency vector to generate. If Hz=True the - limits are in Hz otherwise in rad/s. + Limits of the frequency vector to generate, in rad/s. omega_num : int Number of samples to plot. Default value (1000) set by config.defaults['freqplot.number_of_samples']. - Hz : bool - If True, assume frequencies are given in Hz. Default value - (False) set by config.defaults['freqplot.Hz'] + Hz : bool, optional + If True, when computing frequency limits automatically set + limits to full decades in Hz instead of rad/s. Omega is always + returned in rad/sec. Returns ------- @@ -2206,9 +2256,9 @@ def singular_values_plot( title=None, legend_loc='center right', **kwargs): """Plot the singular values for a system. - Plot the singular values for a system or list of systems. If - multiple systems are plotted, each system in the list is plotted - in a different color. + Plot the singular values as a function of frequency for a system or + list of systems. If multiple systems are plotted, each system in the + list is plotted in a different color. Parameters ---------- @@ -2217,6 +2267,9 @@ def singular_values_plot( compatibility, a list of LTI systems can also be given. omega : array_like List of frequencies in rad/sec over to plot over. + *fmt : :func:`matplotlib.pyplot.plot` format string, optional + Passed to `matplotlib` as the format string for all lines in the plot. + The `omega` parameter must be present (use omega=None if needed). dB : bool If True, plot result in dB. Default is False. Hz : bool @@ -2226,15 +2279,12 @@ def singular_values_plot( (legacy) If given, `singular_values_plot` returns the legacy return values of magnitude, phase, and frequency. If False, just return the values with no plot. - *fmt : :func:`matplotlib.pyplot.plot` format string, optional - Passed to `matplotlib` as the format string for all lines in the plot. - The `omega` parameter must be present (use omega=None if needed). **kwargs : :func:`matplotlib.pyplot.plot` keyword properties, optional Additional keywords passed to `matplotlib` to specify line properties. Returns ------- - out : array of Line2D + lines : array of Line2D 1-D array of Line2D objects. The size of the array matches the number of systems and the value of the array is a list of Line2D objects for that system. @@ -2246,9 +2296,6 @@ def singular_values_plot( If plot=False, frequency in rad/sec (deprecated). """ - # If argument was a singleton, turn it into a tuple - data = data if isinstance(data, (list, tuple)) else (data,) - # Keyword processing dB = config._get_param( 'freqplot', 'dB', kwargs, _freqplot_defaults, pop=True) @@ -2260,6 +2307,9 @@ def singular_values_plot( freqplot_rcParams = config._get_param( 'freqplot', 'rcParams', kwargs, _freqplot_defaults, pop=True) + # If argument was a singleton, turn it into a tuple + data = data if isinstance(data, (list, tuple)) else (data,) + # Convert systems into frequency responses if any([isinstance(response, (StateSpace, TransferFunction)) for response in data]): diff --git a/control/lti.py b/control/lti.py index 81334f869..e74563c49 100644 --- a/control/lti.py +++ b/control/lti.py @@ -106,7 +106,7 @@ def frequency_response(self, omega=None, squeeze=None): """ from .frdata import FrequencyResponseData - + omega = np.sort(np.array(omega, ndmin=1)) if self.isdtime(strict=True): # Convert the frequency to discrete time @@ -388,13 +388,13 @@ def frequency_response( A list of frequencies in radians/sec at which the system should be evaluated. The list can be either a Python list or a numpy array and will be sorted before evaluation. If None (default), a common - set of frequencies that works across all systems is computed. - squeeze : bool, optional - If squeeze=True, remove single-dimensional entries from the shape of - the output even if the system is not SISO. If squeeze=False, keep all - indices (output, input and, if omega is array_like, frequency) even if - the system is SISO. The default value can be set using - config.defaults['control.squeeze_frequency_response']. + set of frequencies that works across all given systems is computed. + omega_limits : array_like of two values, optional + Limits to the range of frequencies, in rad/sec. Ignored if + omega is provided, and auto-generated if omitted. + omega_num : int, optional + Number of frequency samples to plot. Defaults to + config.defaults['freqplot.number_of_samples']. Returns ------- @@ -417,10 +417,23 @@ def frequency_response( Returns a list of :class:`FrequencyResponseData` objects if sys is a list of systems. + Other Parameters + ---------------- + Hz : bool, optional + If True, when computing frequency limits automatically set + limits to full decades in Hz instead of rad/s. Omega is always + returned in rad/sec. + squeeze : bool, optional + If squeeze=True, remove single-dimensional entries from the shape of + the output even if the system is not SISO. If squeeze=False, keep all + indices (output, input and, if omega is array_like, frequency) even if + the system is SISO. The default value can be set using + config.defaults['control.squeeze_frequency_response']. + See Also -------- evalfr - bode + bode_plot Notes ----- @@ -430,6 +443,15 @@ def frequency_response( 2. You can also use the lower-level methods ``sys(s)`` or ``sys(z)`` to generate the frequency response for a single system. + 3. All frequency data should be given in rad/sec. If frequency limits + are computed automatically, the `Hz` keyword can be used to ensure + that limits are in factors of decades in Hz, so that Bode plots with + `Hz=True` look better. + + 4. The frequency response data can be plotted by calling the + :func:`~control_bode_plot` function or using the `plot` method of + the :class:`~control.FrequencyResponseData` class. + Examples -------- >>> G = ct.ss([[-1, -2], [3, -4]], [[5], [7]], [[6, 8]], [[9]]) diff --git a/control/matlab/wrappers.py b/control/matlab/wrappers.py index 04102d497..b63b19c7e 100644 --- a/control/matlab/wrappers.py +++ b/control/matlab/wrappers.py @@ -124,10 +124,12 @@ def nyquist(*args, plot=True, **kwargs): syslist, omega, args, other = _parse_freqplot_args(*args) kwargs.update(other) - # Call the nyquist command - response = nyquist_response(syslist, omega, *args, **kwargs) + # Get the Nyquist response (and pop keywords used there) + response = nyquist_response( + syslist, omega, *args, omega_limits=kwargs.pop('omega_limits', None)) contour = response.contour if plot: + # Plot the result nyquist_plot(response, *args, **kwargs) # Create the MATLAB output arguments diff --git a/control/tests/descfcn_test.py b/control/tests/descfcn_test.py index 7b998d084..ceeff1123 100644 --- a/control/tests/descfcn_test.py +++ b/control/tests/descfcn_test.py @@ -226,3 +226,8 @@ def test_describing_function_exceptions(): with pytest.raises(TypeError, match="unrecognized keyword"): ct.describing_function_response( H_simple, F_saturation, amp, None, unknown=None) + + # Unrecognized keyword + with pytest.raises(AttributeError, match="no property|unexpected keyword"): + response = ct.describing_function_response(H_simple, F_saturation, amp) + response.plot(unknown=None) diff --git a/control/tests/freqplot_test.py b/control/tests/freqplot_test.py index f09d5617d..7c65d269e 100644 --- a/control/tests/freqplot_test.py +++ b/control/tests/freqplot_test.py @@ -82,7 +82,7 @@ def test_response_plots( # Make sure all of the outputs are of the right type nlines_plotted = 0 for ax_lines in np.nditer(out, flags=["refs_ok"]): - for line in ax_lines.item(): + for line in ax_lines.item() or []: assert isinstance(line, mpl.lines.Line2D) nlines_plotted += 1 @@ -142,10 +142,10 @@ def test_basic_freq_plots(savefigs=False): # Basic SISO Bode plot plt.figure() # ct.frequency_response(sys_siso).plot() - sys1 = ct.tf([1], [1, 2, 1], name='System 1') - sys2 = ct.tf([1, 0.2], [1, 1, 3, 1, 1], name='System 2') + sys1 = ct.tf([1], [1, 2, 1], name='sys1') + sys2 = ct.tf([1, 0.2], [1, 1, 3, 1, 1], name='sys2') response = ct.frequency_response([sys1, sys2]) - ct.bode_plot(response) + ct.bode_plot(response, initial_phase=0) if savefigs: plt.savefig('freqplot-siso_bode-default.png') @@ -153,14 +153,15 @@ def test_basic_freq_plots(savefigs=False): plt.figure() sys_mimo = ct.tf( [[[1], [0.1]], [[0.2], [1]]], - [[[1, 0.6, 1], [1, 1, 1]], [[1, 0.4, 1], [1, 2, 1]]], name="MIMO") + [[[1, 0.6, 1], [1, 1, 1]], [[1, 0.4, 1], [1, 2, 1]]], name="sys_mimo") ct.frequency_response(sys_mimo).plot() if savefigs: plt.savefig('freqplot-mimo_bode-default.png') - # Magnitude only plot + # Magnitude only plot, with overlayed inputs and outputs plt.figure() - ct.frequency_response(sys_mimo).plot(plot_phase=False) + ct.frequency_response(sys_mimo).plot( + plot_phase=False, overlay_inputs=True, overlay_outputs=True) if savefigs: plt.savefig('freqplot-mimo_bode-magonly.png') @@ -168,6 +169,12 @@ def test_basic_freq_plots(savefigs=False): plt.figure() ct.frequency_response(sys_mimo).plot(plot_magnitude=False) + # Singular values plot + plt.figure() + ct.singular_values_response(sys_mimo).plot() + if savefigs: + plt.savefig('freqplot-mimo_svplot-default.png') + def test_gangof4_plots(savefigs=False): proc = ct.tf([1], [1, 1, 1], name="process") diff --git a/control/tests/kwargs_test.py b/control/tests/kwargs_test.py index b3e27fe80..22df360ac 100644 --- a/control/tests/kwargs_test.py +++ b/control/tests/kwargs_test.py @@ -194,8 +194,15 @@ def test_response_plot_kwargs(data_fcn, plot_fcn, mimo): with pytest.raises(AttributeError, match="(has no property|unexpected keyword)"): plot_fcn(response, unknown=None) - - + + # Call the plotting function via the response and make sure it works + response.plot() + + # Now add an unrecognized keyword and make sure there is an error + with pytest.raises(AttributeError, + match="(has no property|unexpected keyword)"): + response.plot(unknown=None) + # # List of all unit tests that check for unrecognized keywords # @@ -256,10 +263,13 @@ def test_response_plot_kwargs(data_fcn, plot_fcn, mimo): 'FrequencyResponseData.__init__': frd_test.TestFRD.test_unrecognized_keyword, 'FrequencyResponseData.plot': test_response_plot_kwargs, + 'DescribingFunctionResponse.plot': + descfcn_test.test_describing_function_exceptions, 'InputOutputSystem.__init__': test_unrecognized_kwargs, 'LTI.__init__': test_unrecognized_kwargs, 'flatsys.LinearFlatSystem.__init__': test_unrecognized_kwargs, 'NonlinearIOSystem.linearize': test_unrecognized_kwargs, + 'NyquistResponseData.plot': test_response_plot_kwargs, 'InterconnectedSystem.__init__': interconnect_test.test_interconnect_exceptions, 'StateSpace.__init__': diff --git a/control/tests/nyquist_test.py b/control/tests/nyquist_test.py index ad630b71b..18d7e8fb1 100644 --- a/control/tests/nyquist_test.py +++ b/control/tests/nyquist_test.py @@ -310,8 +310,8 @@ def test_nyquist_indent_im(): # Imaginary poles with indentation to the left plt.figure(); - response = ct.nyquist_response(sys, indent_direction='left', label_freq=300) - response.plot() + response = ct.nyquist_response(sys, indent_direction='left') + response.plot(label_freq=300) plt.title( "Imaginary poles; indent_direction='left'; encirclements = %d" % response.count) diff --git a/control/timeplot.py b/control/timeplot.py index c2a384888..169e73431 100644 --- a/control/timeplot.py +++ b/control/timeplot.py @@ -102,8 +102,8 @@ def time_response_plot( the array matches the subplots shape and the value of the array is a list of Line2D objects in that subplot. - Additional Parameters - --------------------- + Other Parameters + ---------------- add_initial_zero : bool Add an initial point of zero at the first time point for all inputs with type 'step'. Default is True. diff --git a/doc/Makefile b/doc/Makefile index 88a1b7bad..a5f7ec5aa 100644 --- a/doc/Makefile +++ b/doc/Makefile @@ -15,11 +15,15 @@ help: .PHONY: help Makefile # Rules to create figures -FIGS = classes.pdf timeplot-mimo_step-pi_cs.png +FIGS = classes.pdf timeplot-mimo_step-default.png \ + freqplot-siso_bode-default.png classes.pdf: classes.fig fig2dev -Lpdf $< $@ -timeplot-mimo_step-pi_cs.png: ../control/tests/timeplot_test.py +timeplot-mimo_step-default.png: ../control/tests/timeplot_test.py + PYTHONPATH=.. python $< + +freqplot-siso_bode-default.png: ../control/tests/freqplot_test.py PYTHONPATH=.. python $< # Catch-all target: route all unknown targets to Sphinx using the new diff --git a/doc/classes.rst b/doc/classes.rst index df72b1ab7..3bf8492ee 100644 --- a/doc/classes.rst +++ b/doc/classes.rst @@ -15,6 +15,7 @@ user should normally not need to instantiate these directly. :template: custom-class-template.rst InputOutputSystem + LTI StateSpace TransferFunction FrequencyResponseData @@ -36,6 +37,7 @@ Additional classes :nosignatures: DescribingFunctionNonlinearity + DescribingFunctionResponse flatsys.BasisFamily flatsys.FlatSystem flatsys.LinearFlatSystem diff --git a/doc/descfcn.rst b/doc/descfcn.rst index cc3b8668d..1e4a2f3fd 100644 --- a/doc/descfcn.rst +++ b/doc/descfcn.rst @@ -42,13 +42,18 @@ amplitudes :math:`a` and frequencies :math`\omega` such that H(j\omega) = \frac{-1}{N(A)} -These points can be determined by generating a Nyquist plot in which the -transfer function :math:`H(j\omega)` intersections the negative +These points can be determined by generating a Nyquist plot in which +the transfer function :math:`H(j\omega)` intersections the negative reciprocal of the describing function :math:`N(A)`. The -:func:`~control.describing_function_plot` function generates this plot -and returns the amplitude and frequency of any points of intersection:: +:func:`~control.describing_function_response` function computes the +amplitude and frequency of any points of intersection:: - ct.describing_function_plot(H, F, amp_range[, omega_range]) + response = ct.describing_function_response(H, F, amp_range[, omega_range]) + response.intersections # frequency, amplitude pairs + +A Nyquist plot showing the describing function and the intersections +with the Nyquist curve can be generated using `response.plot()`, which +calls the :func:`~control.describing_function_plot` function. Pre-defined nonlinearities diff --git a/doc/freqplot-gangof4.png b/doc/freqplot-gangof4.png new file mode 100644 index 0000000000000000000000000000000000000000..538284a0f2123c93792ef09c10d5172a4c192d96 GIT binary patch literal 41695 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 xt)W$;c_VO44!JQ*WQ7WDQDd~E@oAp{Axm%ac1 literal 0 HcmV?d00001 diff --git a/doc/freqplot-mimo_bode-default.png b/doc/freqplot-mimo_bode-default.png new file mode 100644 index 0000000000000000000000000000000000000000..99520333639d3c6386d77fb29ad6e9dc4a586e7e GIT binary patch literal 53147 zcmb@ubyQXH_bqxb009*#kra`VMmiMYm^8hAmJ7eZ?z1@2Fl3O+})!rfyclD4zDA__;rU*nI`YVy=ip~ieGfwV`N|Q}H zzY-%}$-bn#b;}p$+E+@^XP+L4e$sDlNlls0T~{Y?7{R~)I=}W?4O3OaTF`Ad#TzL2 zBG`Cz^1{rweC-Ec%2F$Aor&u{Os9Dod&{MPe7CEIXC>+EO|Q*zPyNbtUUqKoBVTM` zbPDy`A*I)mFMP3oN}_uIdr|Na3*q-KHYtRar7J4#Bd@jveb6rPxrPl{b#2nW!|!+o1H zW1(LE`t_^thYy35);dY9YjhExKX?83@hytS`R~It@vx58R-dM(XP3vFLh_1=Z@Md0 z8F~`=?t6K8UAun$WL@VfC^t9iFfI21?(emy zMmYTIe|a2{&eS?}j*Z3eZB_Y1MBr!$IMh1|2N1l(7cP5QYV=b+OD56GO(9Y*ujDmJw}4wOdv z`eZM5yO~EQ#Y3}?X5EU`78Vq8wi+)st<5T}W`?&Lue7CKzI-io{QLTdE=#sXg+&f) z!yQuIY(YW6wQlCdd@>*8`KW25V>-Rv3qX zfEEW)h|*zvOiY&G)%l*8h=^}oQc|Ijkr7;LZW#Y=01+8kVWCc=wt)L_F|YY3LpiKE zGZ~psc)P}B&j|mu+tF5m)p+sqkgzb7($dnr;j1vO^A{l>KPtl2=2BBr=T22vD#S4B z6v9U?PuGibmGhK_1_v#cREo3I@;y+M20VnW=HeBO)Zs zZrJ@nU*R!!FnNnYo$NmwHI74a#H>6wCMITWX({8HnX2lS8sk1Pjo7QR#w&%XGSf5y zeEf;Q(E^PuGj<{lO3D{XN=mmeG4uBItl46R0|L;e5XITqFCCnmR4T21X`5al=hL;5 z)M=Pd$NvuL@GV?wMTy$YimBoCqQ=?7^Fb6{5B+-gevQvw7wbA^aEL`;a8Qt9y>^l3 z>1tL*+3v0#2S2~2$7cDMqEbCK_bEgjvw}0&RLbIEWCiL4qh@;s1_mQ4Ivyj3?vrJv zYa0`#a8V-yZim_4abLcuuKcp8Rx35iA?39%zP$x+8R+W|&$aqbCB^?V8tLwqyx1u1 zQ&cK&K^!lH2~AeGoGisCJ%a^ToG}ASdF^;-K}o6LmpP5EiOV8hF$AP+xipA$LT5+Y z#w)#vABnj_;bxMa8uub>_W#J8?yvg509wZDwSLemSLg9O=RP?(Nzcl9`sO>fMuSY2 zL6bL1#fo?+$=Ce+k0l13^P8KNXD5Hs!d||7na$B4xnBSG{LetzNOS@HXeRuw=?xpJ z#W;&boOY3Mf68;E`RaY^qF%Vo3AhMWR#yJq@01qf#b4zw6L{Vp|Mt0y_}=K?aJDP)Bc}<>mkefl`|EV8puH5RybmI3k&P< z)2F4QuC7O0S~+)QWo3CC))eVBg-LI_Z2Dp|`u~fjb#--r1~V|VYV7skdg`t&&+zWw z-`lF)zJ~wy2v0P}aVl<**|=OCMYYl@(XzPO>&i2fghzgbzv;uq7+J}vh=5Sm&aP|j z)Jn$n{U&%vRvU@F<%&*nee-u>C{$Q+}*6(gyFRbNuSi@_)I3!MX-@OH)5{q1r zB%3hBa=I#fyWuQ!d|VYaH>1A3KDWoIef{N~dTpK6>Qvg1Of8er(_sab|K+Ny-qQxX z7fbtER)*&dpF7AmCQi3x%;l8QP+VKAD1{tSUl*B))e7JGbg z^lNpt-pyxy@8DpmV#;b^#_8A6UZ2oPUQxp%F0T9DkZv-i<2%sE-J6g<@V1tzhNv7N zrv)MAeWo|R8a%41t4kn?p?3CbZd|}7)%4LMeilH`W5OO09*)ukvv}O+b@@V0?%rn_ zS=8Tmy^gQ%t@If~nXItHY)T6g{6Sw-Cv0!e4pG5)XTE)L zX=&ncZegJnSC+q7=W_RczB2uo=G)shZ{D={)j(=(ZEf^3PRYQ%@$!@&nfD-d7%X&z z42swB8l6IX||AM7guKmuI)1%d4%e%@6CaV&vQAROL$fn08uv zy06=IeWiBM!Og4Fu|{-E%rxs?4JPbF%7vP*OiXAmFE1JBnoz4y4fIP&%LL&3jZZZ%U=Vz(qA7D{r< zJ1dJu==|^XFu_w=fEsN>L!aZV8)6~QPOUkg?{vPBl{JPO3@6{JULmup+Y#H{+p{#( zcAmQlDcBiyKM$-rxFY1jL?)r~jw6T+r4R=Rm^1^z!tTPV;a%tqE2^-7*aZu5{F@=> zbyTdB?CgM_@my1vqcwF9T2%{_k%NzciTQv<7lFJY%gM5wvBUe$Zf<*Pc|~@+i?7Pm z8Q~J&T$~<6EiY?1%{ajA45W$Q1$<$-+R)HoF;f%8HS6s2{{1yx`#(((W_l0Cbbh4} z#>Llb^HKP&zg9f>hUs~W!k1J8refS64Ee*u7ipv-FE8xu%zbtC>#AxsGd_Vd6kAx4 zLL5L~iww;3g9i`lAT`0NXEpqRXYBvj7keZE3Hrug$;;!A@;N?!^ys}|cQnTz0*l$% zS&n6oqZy})s5W7Ay}Nc8s^}D&&)nVJLtHi|%UfGp(;lSV6V_b)Zyf3H{~c^1f$0A) zsP+Hqi-OgQbx~aFlluGnU8AmGcK#k7ipk-zv9XbmBx0v#gbRvlN}zhDDnW8;85+Wc z%~4lh9~cy5=lC*e8rF6c@9f~9yispLAS@8%vM=ouEM1y$-}{M&kIzFXMbYNMSRkub zV^8OCb_if~2R5VP6MByyQPS|WBWJ5w$+SJ#LkSS&BNShklg?Js%Z87o94>rRN9G@C=V8N|cZPl#*-P>D0 zMiK~XlOCd?qL80LB^<(ql)LWe8kQD5WzXRgr_sGb4y-}{K);cmOWXV{{OF_D&q@?uGu6Lan zggo0?s8t)MScKiOqgf(*uK=*VXdaj2hAM0;#9hJThpq^Q~()Icpr&QJcPpQ|=rdQgd<=ctg^(`Q7x6+G!Mq!K7TB zUqHap6!s*WxH$U#A^B6hftoHl-GMb^Hma-R-`(9s#tsySZKV#s)zs8f83-OddSo$W z|EK?5SsDA(-ltF4QwD&k-~0H`3otV=S=lAP)`G(%BqS_MA?m_Ra8+AT_+FGn3W}Cl zL4B4=6qGtnhK7cgHemAd)vJ;WDi}I4vf0H8XBQVGqW%%b96wIWNo)Z9%|2+Brna`W zkk-?>Y|$x5y7AADmk5i37p~r_W|cb0<4{dPQj*914>n+GL0A-SpVzmhDw&f#4j&K% z`@sZL_}`Da-2I^Nj5(IJ11RugnV^%c>%S?-sRc$7*Ey;{HC+7FRZwj z1&K!nGb95815NEngaekBo_PF76%GFT_wVn|vVwEfD@hPre8Pk-tKf#lvY^1_<%6PB zDVTt9K1xNq`PZ+;!JO1M_ltuuB-DwFin3kqc>vjN3QG=yLLFmOp)KRTV-lV2e^;{q zr@%Pz@>i#xWt?my3`(}-k7bA_q;gW+4j1sot@I+7Fbwa z4J<0M&gf|1FQ~9Lwh>_nDJcCqHIn77WwR2yU**CuB4B zC0S06dy1lZACx(5YX=1djaOI_BR65K)3y=pqux7`++POXP~_n`CiPO4eiw1|e6V_w zk)BAuxdnln!0{R0`$GBEvA(kLIa952jhMg=AM8CxMYrXMTVf z9kOn`=z!>f)&ze+am4oqG5$U$SK@hW?C|uX&?UOaEorLSaS^fSv708^aGvV+CGR_j zP(?Cgyk}ma)>VFg;;<-Y)2)QP4+>K)#4$6LB2%ke2@5 zcT!uVy~ku<*$9Cv8OT>(LfBlMpCBO+#KjW#6FaNM3r7uYWn}`u2S$6#KLI6vvtO0( z`1s(3wl-NJpA%{mEcRCayL|NSa<_w(GODJfJOcyaWOkB2x|nDU-D+A&$YmpRjOH|L z+jSXA`(KKAKFFZ%J}D@-DO)}qHdlN!>3nZ_`}vn-G(FEP-{U{8cx(f@3<@dO`iX_^ z3IwIR46aB!~B{2wAiaXI|%%NnTJ?W}T8TD&2g;xu;A%|3O=? zhh*@bXZYo}R0Y@ut>r&ZbO94uH=I!S2^?ug)%laT1^|cgt+aHaSnoO@a~g=4z=U96 zV0Z@w#sDz-N#f9LP?GzlK$$@q|FdYjz7lI3BVJ7OXXR^X^1CG|%_bGP>}qs;S5qbx z`s^CCPnFJ@IMir4ntmgJG#ClA z(a|bD&Zcl)z8o&LzF@@*K~*Z?HZ)awzI1M_B&yVGcBU1sQf(nB81*A3TeCu7_;prL zyBQUqc=y>WJ{Aq;J8M14_OjWzce7hJoU7-yb7~d;mPz6d zOim`()6=Uc15(T8_^%!S0VaNaenLTasI2ypRUXHv0Xq>ZQAowg{ZJC&cJKM!&X`QY zMf|yY*)=itbQ((`xcBiyXNe9O~_piO2mwWoTtF0r1_yJ#q zPeYV)O4Hc4s<&Y(b?ZyvQ8p+>6Jg!Ef zX)M>OHr4IoIS&q$70VA7*ndK~7}CG z`8UcGNfQuiwt$gw$;qwh_W*rG#Kd$)P)V+7ZNI;Xsaj>D7q!3C9Rm?rZN0)p?3hQ( zfNw3G_Wt%*7A=1F9X!RU1)}bRzI>+o)X?r9n$e!C1_jsRBt6T5)WcCtIPe>pmz9`` z>NhIIGAl>AEc^r}FoZHpzta{tH|VUwRc^(bE?=xw|otDZQzpsgJ0#n&Ac7mX#2z z;%*tBhrywtc#hCNe@vi!>-zrvAv-&scU2Yla!>3~iYpMls6JmO`GFD0(94s|W1M+F za!=A=-Qthq0iBLY?1zf*`bm5DKAVmsEMuKPo=fX>x(12~Lp;TN0 zgBJsw^ZjO$mP4?9GC0dS*EJOy2R~J%%a-(2gl;I9b${k`n090=k&ZuApIRwglwRE4 zmQ_|(&M1SxiAzSd_30v3iW&)))C;1>+yH6=Y+mk5mNhk{Gat>L1M*rbiGPB^7F|x~ zVzP2>Ggf71Xn`^oHmMcw?X!qo=ii^}H7k#eqqn6}dlZKxOaR2Bvc(W01Yz3Xqp{45 z=QzRbEtBQu)eE6~B#x&?4QE?O+yo19g1`wXNPO=M$(V?Q1SwtJhqC-TH+0;0ZbB() z`;tVMU9s~avvy6%{YF8KIH9n$_sRZALlf=imLty#T@Gk2_l92WO8iJ9%>*^z{t8Z( zH@WK{3S^ZGm3Lr3!0%sImt0;a-8xmk=#hMDgo3dI3J|kBfVgUnp7k#d>3!>OoDn+f zyJOuA-RN1V{p@lTGnBp@wcB@_Mzx0O-4v=Ao0|z9>9zU<(p=<87ICgW^IjdwdJ2q= zu|I`K@VsHaZ660|_1l+dqogla5(~1pT7pWNN6yw~M zh-2V7NAjk1u1;0Z#2<-tXN{SC87(4JIQxLifmde->>8oQC9A5`@u7x;JmbL=FVd=V zylz-AvF^NpnD~^(65$Lq-qZrH+{y*&?Z2CS0zZCy%)=9_RqJRwB+bP`&rhNL7^s>7 zjxYDLe0ZXi+Qb_Z=eh;x9>2lgn_g?JJG`*9+9H<`$tk787e=3xk|9QT{%uC9=;Zq4 z{*+#&@XgwAPDL4q`6#l;;h|)wC`3+?Tw$V};f(RtO~$F9 zqh0?zK9(8{blw6I0zBb>(Kf1O#3hgt?w2PkQG?Vn3EvZ)W>?b#e5>r7iS2uch}siX zoOCtZ;dYnsgp$86uYATAf{-}P%Mvk#CWf=MJ-1;4S&;pD%<<%}Qe#;HMs?o_3)ChH z&b>iNW+VkcBTn5G^&(BKs=}OQduAfp#pCC+lQiBtZL2qUkvrScG}X%^$yH7hR-B{) z!R>4Nd?Fw#**t`m=?Nr@J1h_oiazuv@&Ro`41_UFmbahHC*8Zp>&Jx_UPkQJGXr51 ziPuVUi(x8`JdgRt>P~U3&z;}zyT>ws9;NaqhWqApjslJ2#`tsKBvw~fk>p5&ha1$t zZTNjDLu}Qu+@H^U-#*C;=yRmnSxUki-0OB;==^-{HuOrY-IvAV+2gcYzu%c}1Ghf{ zc8vQ@h3Xv}8;hu_tsR28R>BmRC7EE!fYEe?Vy-@!prsvK)^i(fA6X~rl6CvSO?fsH zZL8_=BGMr=k$%SEv57gjMU!iAiyuCGNTtQdRcGD)Gl)5=&S2-IKoR%3R=az+Ve`vm zCFxGAsCzYyU(sRaCnb}`3muugOXjRq(z(uklyIq4($M5A^XUL>sAVpy+YtR`<;t!} z;o(p-2NUu;7k_?)BaE=WwX?jsUc1v)Re0|HC^)Jy>+ixqDap&Om1h!y%=rQ>zGPpg z$GNc7xIpw{^q0g!FyDtNaK1fwbvWmC#y^Yox)Z|-)%0DwY3ZUC+@~7L9N-Jy*X% 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 Bl)?Z2 literal 0 HcmV?d00001 diff --git a/doc/freqplot-mimo_bode-magonly.png b/doc/freqplot-mimo_bode-magonly.png new file mode 100644 index 0000000000000000000000000000000000000000..106620b9599953853250ccf22a04b2124a8ba223 GIT binary patch literal 48186 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 Y_lz{3W#6oT|2yjHWi^Ela>kGTAKGG(9{>OV literal 0 HcmV?d00001 diff --git a/doc/freqplot-siso_bode-default.png b/doc/freqplot-siso_bode-default.png new file mode 100644 index 0000000000000000000000000000000000000000..f07ee416446b13bbd3a394319dd1173cf1161ac3 GIT binary patch literal 46705 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

Vp@epsA1nH z6_oXOb!#+)xD5ko`?dp=Jv7uxTfdZh0&5@i&ZD} z715XdA0^Du$BsFfDTg?TI@Xfi+%NjAYdyh5f&NJn0fgC<|CC1T*D$~Z*+%NlPS{c4 z&Nc#H-Rit7D9^ZIgCS$`-`ad5yuUM??P4?b4dTi#A6Zz>eqVR?sP=Ej&%O4n|LW$W z!v?ipv~E_9xcM+)ecBr0+Ll|<-Y{G+7UWg{BguN^@!&z2;Ae}=|Lu`W$X_$35hWNK zx|w9f{`yz1e_P9@ZPT8h!ySFqh9$W@Zuf6!d8Q38tF}BfVWjvPJyt2(FraAMfz?8 zCsx+}k2AXMN1`T&%mdD@FJxL z+lI#q8NkVFJ1!5J{w~ooAr$gifM;XXyjJU-koSKMZlPFO>uJ1{pi`?MOIq_F)n#?$ z{DGw$R_NI=*|?g)hc^q%m}gZlEjIGGcQ=z>e`cojRa{c8%QdMxH_WG(xn|YWZu$

KOg*ELmr~X2+gK;5Y^&daHRD}$G`1K_pn`t)_21v$ILK@f9)Xi@p)d2| z-&HjqAD_i%$h=rE$@uyCb(h)bKuA0I!59=HPer6&$hqw@9=}M}#4uj?$luC$%KnJC z?H4d<@#9NFh;}VBotcT^6=?O?{i-S$P|ra;fkQ|LvyG9D7uaOo3y#%5iCHxtzY5+A zY-En()@(-_2npet0qIY};tt|pN=fksec=|E_0|D7WCzA~Myh0xS-1g+&wcP3j*X;y zNHNzhlP6Df0u=mz`ctl+I#OwLXn*knAw4>Ea0%ho9Kc}W8#ixa7y?^IK3OCY#v-xR9oxew$u=^B z=RJ>q!!79Xj_I`P;tE=o7n35ijS%{_xZ#G?Gzw)u1SB#+b|QY}l{3kH-U%iv%&%NH z-}8vygJ=@&;F7L0RGSyOz+>i6_DWjNU7|QE3piQem%1k_i^tE;zdhfR=H>w+#igHE z|A==-h;Bn}X7r~i{G2C4?crObL}P1BlMx#D;;G$SsCiw2H=`BRjaQ7Dnm?|GeQY;u z85a^c;|V`ul0Dcgm#;m(74Foa&GL(@vdoMMU!XjpcOCC<1)qHW3>uIGPGY4={lQTE zo*55@xKGc`rDd_e3`lB`=R@ge0+uiz6rY^K8!RThpq@lD9za$D6oW9>w*W6i3yx8} zQ)G57ut1DK*IHV}_jjFt+#V@a5GQ4n##KzuAAz+8z5acS%CZGF+9iTOIYW<-02)j@ z^?s2Jj(ZtgSJX`mtKZ!7<&$B%+iS-|l+tU4>Jn+=bC{J!MpwbeWU!UMcwi3r53ouT zY)*y15`(aGO7q-dNdw(^%fy~ z)bozJvmj$~0pHyHd-o=hp<#lb4WS`M5QU8B0&~+jgf9cmZ$ThczVEku85(K^h*5wl z(s_d~+-}OBY+yL*PXpl0iyor%-+#qM+7KIEuN;7l+7hDUC{7~xfLmITx^Bz#psP+j zm}jXlA-t|D|L87<22*CWYuA+lYL>^HA02tATg_%_b=6nf()CoYIO|3=?u)eeVV1o< zQU9Yb8bMqDKkdK3EYx!_pf{(`8U(q1fq_+Nu7TidCW!BV335%9FfA1kt^vdJ<=r(v zZ|Os_1cF55e1vJq!Y~8ZDm+99POIN0Qrk^j3o75m{K84x8c(by2RM0SD2&;sTe7PP?A$K{``gSw@&3+-cXA^E4s#|qUN5}!uG80X zH;~vzm7nv5QMy-Qb|93zk+J1(uS&+iL}wTv3p<_Bj$yO$<)>{$|2$A0Lm z!=^C1dunq(B;ut<;~s3U=Ym2)On-d1ziNqi5bh*PV@M|Xe2`Ni8XjGsR zo6GLHna7M5UG{4Fhts?1C`VwJ%N!gJrXftdH6Dv&GaE{cXK!Xg+8&UP-T3%Tp7KJY znj*G0d{{SuV_@Pt!>E|h3`2nvS3VBIAxH361t)z;+ZGAyLMg#dqkE^!qMe3b0?M0Yx%k3fyF`v&2V_mawhi_{{$8o#x3HZXPV0$gNeE4bl4^YbH9@??C6=7sVlNmq<6P33;zHH(%-IL<4RrcXVbfq7S~dY}7Z+2St!3Czh|U0o51^MBcR_aN!SYE7u=Tno+b zVLRo95S+6ALR*K!mCxalqgcxNCR2fl{Ksp9^1Cw;s_bjAq~B>y!YMqAsIiC?Dc}8b z5-tpP<@2}(^RP7pbxNh+mj1Wc;o%$_%(BwYml>Q>3|b>J7zZf?&Pfyf)W^N|OXPvy zDLxJYh+}umQ%ew*3zykLtXIw^#lJN<1w$@vZHAqU^$EVzIb!VPXZ!k|H}Nn1E>w>p zB|$vD`hy>d#>y!lU`eRqK7UG;bH;T0m&D-0eR^Ak3O|FS+F&MWCq~Zp5s?XcPu2WgX)gT@8j@=|55U4XP^-^?uUw7d3%U9CQ!ZqG5;VOIzy{;_GmZh=o)=2 zFvusEUW$p{3fyXS*~#$0-*4)YKXE+&%E=U{!N*!MFw#h;6Q!=Ix()~f4zJVaFz1|u z@(rw$2+`AhUtkmU(dgZ#`k&5cCsz~=&BHk>6-G8O!85{$G^)amO57zssDW=3Gj{bC zE8qx^I?fIEBx+UP3_KVODHblS5Xd(y5Jys+idr*tkB8S56}WEwkzMb^ER+g)n^i64 zRl#gNA<(lU6Ji@1C4d=C0dO&>dN`E2=7%BWrk+jz$P9;r_Zl0;Q7XhPXjFnC3wC++ z-^_rjA&k@;1{Q~*vhn<0gZzr=fM8!g#|aYuz-#x=U0dp7TF;&|^n zc;(&H2v67&n&aP?axs{jK7QoxZq|in-RJo@W3qBriJ1kD)BiLo?nnfay)IyMxc)Jh z14r@OH=_EcTN^hcD6?|g4g)MJw>~N!*D7HPnAg~hk_p;1RSrjm#1gilRcnihp&<8w z6HI494v3exZ}-vu6ui`eW-SEY`gE&N7X5M8tvYshJL52hK5@CB57)mO607(QV;^!JZl`5U1aoC+X^C`pD}YxxI<|KPYT}BD5Kg-ZnUqa`Zk?%D zGVu`?&ClCLVjDlfH`zJC*q>T>$4WaIKjBDNZyhkbjegnFGvVq@r;U-P3`>OiSL*t@ zQ-5DTUsgqlKm>#=7R2lT%38#ok3d+z5Q$ocuI6TCXIH|40<(bRdu8B*>scK@QU>;6 zvFv_u_I3!aRU7M-Im{_W$TCpA`F=Q5Y!<4A*zy}O{q_QJ*IqPYgyi@pc9oBO9JS~h z6TKb`l3c$U2d}2%9=>jN-M|+7j2+pegJ0*Kc6l^S_lp*Y98{C~rhXf`g>Bd9hbte$ z(V+iRuNRPr2tl3VwmGn80TCdy)Atbk%})=vEx?+g2y@uMFNKx@%pf+ehfsdT5uCq3 zWPFBe(C(+#rPGN&&hb$#keGJ9ZQDpMRwU=3VI?%~UGrsuIwigV-KmSvW|hjR6NXa2Q{K3Vt-r{ic$V5<>97 zBBa(_+XUL(@v-MYlMz>Jb9;Nq64cWqN-h+JPtBec3Y{$*HSDOK$;!7FxvATkA0K?Yi0}233HNNMLr&+%y-H2iI+1H}-`LDh8abK`Kutl# zaGMcA{w)Cmkr6EU`lPtKb!tx%Fg%hxcC{b`2ByL90Ug^?wzlTxE7z}|OH^z^=zpSK zBDA!$JG;h6?bVZiCvQ-vQIY{k8Zd^cMaFn~vv}?e#I3C8@nRQTt<&+7+2xFgb?3}a zzPp1l+8}4_?#27r&7jdyU&v98jP`T-d1&-m3&yrNGuiK7O?z8%li`Ejjj8?>v-wYI z+=~Ik0ed*Jv&~nYS1+EOX&ILdlN|MEul$5Pij?0QCjZvxD*>+zis4Lrd}QelhEq8? z@cezi%F_##5`i`_4MW~wJzDk#dKkYJ7VZN<81z8{ZS@Fc0b!p4K!0bq4geidtXdD| zJX(-0PT9BY&wdw}$bK2KUf~AgyOV2ok7Slc?1cxfNI#G=-%8AT2yzjY70BQLLC^}LweN|GqY1n3LKVBb=LE1>K(APJi@v2< z;0?g3W3KfjQg6|Ceg{TQPR^b2*F|8P)Au^rv3inU%k2$bB)GFU|K;wE-X)xT#>`dM z^xO9-GsnY|jQ$wgiejx39<_&i-wbn%CMwZu;ij(ky6Up!<&;)Cye0aQ>PvB%roH~t zVHe@&-(%KtP4XGdXh_bCIN!j3gpl6;6qF#3VcbE6c)Q~_wLa#03b)8D`bOm7*SK$$hob=QX#C=)ZIP06RkRFmELfkdT7g8h>t;hF8=D3R2Wg0 za^o!O_fY`J1Rp7=y%rK4IDMQc;Jy{@OM#id{%O*ej%~exh-`gRb#t`y1^&W#i1<&R z1+u&^y^>nl?9TD=Hd8UfW1cGyP~*?HI-Wh*M?C-F(kqhLD<#LrS|T$bz`}rfyouKq(Qru$%9`3WQ4$z@y{f;v#@y$i{}BS%Y^*HzpA8bf2-B zlf2W~HzIWM`^ykowQ!5Rq_2Am_ns$*KxW@L8&z~>Ei#c@Nc!U2CF!!{cB7*f>iY2V z&gI8;<%Yjfedg&X?&XJQ3q^Dq><7;l%AxIpo3zld0bM$jwgai&0_;Pk_VmzdwYv9o z;ypAJDrSqIa=RVZ1`W9{(3M&ip@m@1=APYcd@sB*c3<0BiE^C=_Mx7%qF9OdDegg{ zKP8W2g_udgTjUo_gpWFBH%=CWc9T0`{%Z5!jpze`87{*cSB$I9aiz#zIidHhxN$9| z#hLv4oLMybIuwx~L2U8$`*(!x0C+R7PZB{>E?D?8f>6@#daGr(%-zgS7(dsbXUp$) z*C|bvWuEF|nr%#og|EQYj7B`p8;Y+D?T=35wVJ2eC1mmYmqjAmRv$*i-x2n*?5&f# zQnx^TuMUUC2u*}pcXG3$g13qkF$lpm(I;nSq}0?d0T>zv;0-9K*%H7r$MM(s9B0u_ zc^$iZch`m&d!3Th`4}VA*;eLV(Tl}+5>97Y>pdIY4i$%9NnE}F%YB<@u`6Me;js@j zWDjgjuLg3x+J}E=$l@Qnybq#dCq-L8m27SO`fhiN(@5aZe<=vJmt=lvX4|3o77cge zHEa$xwh*tAC4(`?UIo}JxFKn96re%6lV+U>7EksFOYLW5)$?=#H%|aX+AwNJOYAQ4 zC3yvYI;J5#ax^vTN>3?h3pJ}GEvCT3x68XL?)K7WD;l#rI2D_4325qdMc@nu*iV{g*d86nqN<`j$yT^jT-; zrG#%p2ZKr^-}z^VHV>cUW`>50*OU2qr8PzEYv>-z`o9@3@|*G|J)B1ApNv-5xbK<5 zQrMjzxKSzpG4kSD>iXH8sVA}Ox%cf+HntO{4ydKHB^>&sfDVU^p#y4Kz7XtWu_N0# zmOKtVHI_?H^=e)&jf@}aKdpS179D&iL8X58Oi=2oC+s&@bZp_vP)0lHnVu*+k=wH$ zKeE$Ad#+NZ3TmE~?P&~-1W-yUcEK?MRQKGS)2}b{oX^+5mVs99jXl{d z_|~1o>5PUXO3>}D;{CboN9K<1SkhR;TApD&X@L*4Fqn$v?mX2}vW!aVYjaaHjaF7y z`|`eBg3#ph!0Hznsl8oK7d4j3BKC0oM8~_%-Q6lnxA+oDP_Q6xs^=I26Y}fVuYHG} z2@>z$q9f3jrA=k=V=hf@+OP?q__L;~b;Q4dk89NMgYX}TJ$}1Wvq*Vy!PSJi>xaXl zEN+H_EPwbzSyN96*b4{~+0i4G&lo#Kq+!G28j5aq@{E!xpnJFS{3ZTwT4J)s(wZif z5j=uxUT1>fVg8_$EV4U~WchZqKV`(Db*?zDFF&XKp6j?Bq#KnH_`|-X+}A62@8%nx z4|9=Exw3;gjzYZi=*_lTTbggC^(l80%DIK;{TN*6GFZ7k=Op4n5s|PhpfI?5diUG` z3GKY3Os;coa`na1;dB(Rb{&@nI%xF}$9bpJd+*Vx`ce9+W^HnzixN-lwm*%KLO9Cj z_~VE05cbTsGvn>~c;!r0Rkir$c6NIDi3j+m(`VbwH<_|4d`KjDnu=vn3}@vxYt)lz zWie?=$Nex5AzqVoNnxissi{!SNl_B#PaHk?J+n}Ync9JAsHhmSa{!!Gm-PnhSSX{c zjF#t@>fgH;w36l^@jq+vnJ$R2WUU{pD=I~1Sp;$7itj&HtV z8$_5@n%AKD<)9I-b9R2%X%c5~5{K=wx_#!Su$*6buX#+_E~(jNegctm>3i&(5BE}) z#e$?2g47kTy?AI!#vQw}7E;uDI|sv;?U`9~uPFC!pIGbqMJ{!*HHT#xIE&bCdBY;-Sn?o$>|H_ap~)$Taa_sbB%tcuyAsFdtc@q>X?rrM=g z8m-z^Eg}rq-A=RyT@nYsv16`bJ>Wi&Ynw3=d|PSZ?yeIOn4FZvRa=UA1)S4%CiZL_ugU#ml`}ReU4Sh#7QbN|XA-@XxL8&DDqVRDYJv39-@*;_CCh4mXkVX=ByaA~u;D^_H|QRo}!04SGEf1sjgoSIS{Tph-;DNCSx zgUxK>J^j9m>mBoH0^rU4EHJ_V0H!HetrT zv#_uL$G}BmVlhoka<8M&3CmkvP*cPuB9h7O=du~O4faxP$7*-jyu{~oUwf1LC^=X~b-eZ8;ubtx)^-uQmGNkP9hi+6OL zi=Lo>{LXK(pY9!@j7+(rPn-de5GU@x3+vua!B9Qmpz_0czK(=o$#mn-jI+&NEYV4Odrr6rR3+|NeHkmL@@y;7ZG! za@AZ^gs4_)Vd3{t^0{9c=9HGU4^rmt=>|Hy;8x6^<(SOiOc|5r)z9x56(UP+%(c$x zI7@7T5Ia@OtW2vqh$nrVN69r5&Q+*fpt64gXZbe6T;{8!jEm_)LkdGJ(;(IwojDk+3zwg8=^yDF91~X-~aZlS}`$os^H8edHDOqdV z@r>%a)bs0&v;F5=)Qj+<2Ktduf>^hFO;qc|8VBYOELKHvLm;&w3tUavOTlb zi*wzIrLYX4E5c+%39&qp`-{-pLcT6|Av$OQrV#B8>?hQcB6@pIf~#*+$_*iBwRgLR z83aVTb`1K+#`XD`R}1VGC^BXM#&8f4DF}28&rA?-q|;;G4Qe3kBxn>;>p^2N1()!| z8%=RZz$aBtpZ@9VOT&r4@}6lS4sLwSrc4Rw&zjkz5xm!Tul*y}l@j-A^r*y< zr!8m53SZynaE8L8jVapl-%;1!bF`tS<=MsPcCX&QIQ3(GvN*oY+AAAI4q_Lj(@(5f zXy*-z95$nNf+g>b{*C=)W%-`>EBdZ%;#d&>jm7*-<{3$mK1Kht>+0(nLKkI)!1V|n z?yMu3lb&earg)+Jsz#TJr&!wOdJCJjj?NE3 zcWuIdfBU?5X!GS;;riWD=7pC`&yBA!W?`LzG{At&EsHtV+iQ@c%@Qr6TEZLLbJHH`1 z^0fJ!jnIx0izR%jn+>!F9*h$FK}hwLsFr6eKFD_e_jR_?T^ma#C>)PAUYIftQ03?z zI=qZ?tLm%t-JY=<4tmCtd(``0{?cXbd8MU{sQ%eH)tXyWGMna) z0qN{C3BJ3U@k33A*xTH7o0y-FflN7f{#KR=29hl|P+t{T-NdwD=zEPr?mw8duJDEC zmHRE+ePb8uLN}hp!mQ$8boRR3V)%vOp!*WDt^4=o+Hr*oQ<~nq;gUYR0+n~vQKNA@ zK@&5*_V%q?Lv48?P<Uzw$CvvmEpn5mFXZ`6w0@6&2|6 z>o_<(b|!G)P-m&afuvjD6?K37_!JVh+;S)Jn0Q`UZBLaF%S+L-;)=?VL2vZ?-$^bj z=zSx5?NfU}u(I3Fx5e9_7Xw2rnBtkzOK;v}Lq%%lgRr%}VaRrQh4| zYfacgPlqFH^MUvF$C-We-asvAdEh|#26q^CG3 z%Yni@qc64C>w{eL=5B9N_Qkq1b^I|^)*uIEd>|)HtDh3Ju1_Md(wp3=8Y(xe{o{rd zuPCmZE~P$=zQJ42`eWTr{(EYtgULP%V5bhbuqwJT?^RqWZ1zHa1${ivc)iXKE=%L> zi!|i!Yeq3RE&V^z61?;{qgb3O&UK6N*XqMt%K!ZGs{C+YV%1hw+T8RxE!oW{f7Y|V z@_ug{DdRA`P@2G!eP;+D;U)Ya5vTOLeW-Vf9K}^}`pp)*`>h3pT|_#Y@9+spBy7~! z@bp=Q>M8n346glz;2zEMdONQ+Xl1r0G!5gtU-kRUKaVpX8Og$#U;Fr+ndPHHiPmbD zzMB97PPdIzDwT8zu6BCLC*e}cQDQysaa3>T3_&8v(i);}YI^kff7~7adXD9rvK0MS zwx0bVE^S|;D$My3UZ`B}?c)?Y=+1E9;E*D7yzTWN#j@$Bem*c)tx#w2|d3aL`Kd4{Vc*(@Jc6nTR_UzR7P_6GN(&wvT+52z$dpYQ>gY+!fyYS!m=U(@Jf@NrsDzyW&niQZ(H| zeXT3i%Tven(}mUT`?>r+2>`*^W3%3`WKs>(-$~hlB100s#_5aEwc@a7WHMTXX)ou<(h@k{i!&2FCPI-MzW5q<%SM z_e&~%Z(Mwph%fcXtAifxCk_ouTpUT!$G?`6DNgz#ZhcxO*X(1ViG*-hJiomF?PLJj zQ6Eg<*Y*!ZT8XnHC*1~yXaqC?3#sAJB5etouqGDlD z)X|~;F!tlhJ4`gG^*amCtx3=bey*P6IU+3h(z91Hww&=9r)rEv(T1XJoCh4Y1wUHN z-W(?Qm2(-RoMVQ4`!V{&*JY=UhbiW=}cvk335$ zX}gwuEJt|*sVg9R{J!I$ICDU*Vn*^K?iz;_v9h{2ldf0g#~67zejJKs?T0`EGl$AA zQE^7~?4+nTF0Z(Ey4#~QZSCZQyJfDv-70E(F~viOt#o^LKa;STqN1B`z0mF-^(+%D zS8G^HuBi4eH+2^bwN PLSiHD7UrM9x0<8ZTo&LkN0;3K*~NyhjyU|ck^aNYG&C1 z8f_y5H7|Z?>82;{&^i_``~Js*T>^0>tn^%hHWD!I#h_^B72Z0P>VZYBmMbA{HsSYI z+A;x!jZAu<4xVAlOnR_h*grP@=EyDXx#BY-zh<1C<{V)4UY+vxde2~WW2UCWv9)@7 zrZ^Y8N$W1S345DibvOlUiM3d&3YWl9WT58vo=um5^y zQTC%10Tzdj=xBRI-xl&2YEGBt-4S+2I`sLMm=>YxQ-XV9T-HSeZ>5@hoOMB^)N!A=XJVqZ2hmQzNA@c8*Fe?BoQcTY@CChBj&!3U$JmtJRp z6uet{E1d!RVg>%9`1}vJO|FnrBnBN0y34D~)f^p~+HRhfVUvA!LqR(soSSYnEy2IK~TkDVZ@wS4@%Wm!@`89Z)*f6tjwWu*` zoVQ_#)r(>X0LKiv{!?N`Am+l1nX*wyDGJHsW7nw>*G39laPAyV*lXagLJ8b!uH?^D ze*ZN@pr>qXJ4-I*iHXyvT*0k^`3BCnr8rCeKJvOB8=Su|rsqJ33a-;`Z!e}xPLAAr zzEQ#zg9XFqF5 zNlA^c`~0W8l(|Re{>TcNI+0>S35dkeh)S}`%nS`J-OAw406P{P6p}3S0^xo~3M7Dv zTDU9F1SIR)uHtdzi)*rR;9dckaKOx!ZrV`TA|H5eoXVZYH{F?`^fMlw&(jNSO0~oVKyxCJnS;9+~CFYrwTnzE`W!7L-Cwr!9 zq{?eKhzqhsQS4U(PoW%LQ&ZK|#rMqFW|$XwGSK1@8?#o!!NCpM2*+qmJW36Tk}=ms ztXg@0y?>*j*S7t_2k(~RPKUdZ4F&ZB)9>F*KF+4YIFNH8eGICGN}3i*4RLXCf??Y8 zdt)?B7G2ncN^T0Z{pr($N@)D0Cp24{%+XpR8Y?;S&j-(04JQa$uP?Syr|)>chsWmp z`(rbzwW4&+I2wgNS@stP;bvYisBYeA$v0YRa{22SO+M{&P4ApH7&h_@Y?eE%VstXH zel#a((89bG9?s%q55U<^8=BuyzMw=UH@)W`5!|gAQY3Ugq)xLZ&G>{(&AMG%E9sL4mAX4c7h4l(qufM%YlUle3G(f9~i&_8}xHN=l(nv-Fu24q+=F zu=@P~w1Z6LJA@tZ^6~Mxj{Z`wf&y7kUW0hO**^tDfny)E=h`p7sM5}T+reHH zZFM)96y7IHqI5JM7M~hylxuC>^IfQXNk3{r~ZD6hMJnkWwI!-O~M1p<%7Gh zuy9@Z%!lzhd!Dp@Zi%^7iFVQ^?uMxzB!gZZ8GD7I2IdR3y-PP&xN&#>h3L zs{Wn4tcZxnx*eA68bRC;&%8!T#a|D3Z7A+k3=9l>R$c9bgd|>H7=#SR?tNYgMV%#1 z`R0jGX1@sqet8wP^kz$>mQQSoiN!baptan0?OgI3w6Tctt=n5X1=jR(uB>l@hH%jo z)8>xOsez;%9swv(UWaW5-9_L1K`Z_B+3hUrL)g|z$$E+0yd-=-M5JC*h{Cf;Uj81C zG#r}%HBs0`E-h!LulAyYJwtWiq21n$?czxks}pDzsHr)iwM8#|5c-@dvpuW;JbnHA zs4xXt9>k;Y934SCN~e0ZoU)-F+c>E&mmni4&f~^;z)044L|^(4ouGy}@2zA8`}!h0 z=4ZMh-L71z13zXU-KaqRLRM8edB9wSC7a4Gu2@)tgxEl}3Ld_hh* z@-0yp1Z4aw^_X~ZpgQyGpJZbf2Ean;ikln85CaM|SstatqfjJVW9PsgN<9ET?mJ`AdD}m)574mr>Ca`w856C+M1fb9T8B{Cat=TGouBq zBXmouSCwl^IyxQTD8Fj;YBk{dguR|d59E^cLpc4y;J*B&AeohuGEvgBxiSO5iPw!r*NG{_y zzE0*S+-0C{9mcfi$`2`kNd13qjgEyX4WP`B`}gZe(`DK07flLCJ8JIk%7cwDXNdP3 z7~z4P4WKCjsO`pSV%3)W=cUO3dtlJ?FEB0j1)U)jBj^Y_Y66c?%I4=TV+>!ys6$Xp zYyg!HwbW(@m^5eGeb1mP!Gb%k!CsBs}%`cchH(^BZR zS|;R2f(st4bot^~(IC{k3AGH9-7Ie;>-N)0-UU4*yC@LA_Q{hYqN;L%#fM~eoADFv+n=E-M5qcMScu#bmz zV%o0^NRI*LKHiGRD_i;7)KpS#E_>FMf(%rWrlun!BF>hsA+~_{><_c~mQXNRd8XoK zW%p4+L0Ew^RX|0BFQvGP>J@*q9jpEt=B$@a_lLC^y(Rr)UaP49%$1O8+(yT5BK&yn zM=q?-LTtZ}8TFtp?m8z?&YiaEkt%wS!zd~4f%hs(vXgJ9Gte`XM)|Gdj6UOSj z5DAUL+D9>s{ul#nkCVyJI@A5vWPevoAnHD-SUV2C*<4auY5>#7+s`-Ou(Kg&MjLyQ z?n{6=m*9pXC@c*B9(&+i=OwqJmk_lP9YkI5qnyRfo1H)UI)MpoET7u)mS3-|$Z1qQ zZ?bmn+UHjp78A_@qL2FI$vxbjXmD!ljulT2_Pi*YQ2U>U4K>DNjuN75GZd%35+dw3} zvFp=E|0c>HVcaAv><7nk!(SQTfI)cp$%WAa$W%ai#4?Pp{Jer!F#y~$f*#L{_`XF9X=cz%QREzuMeaWSq zr_ZxI=G{>fMDYJg!5DnQ~@|A|?PmhD~${QqCC)Bh9$|F2tlV4guiHTd@Zcc*Sr@Mr%Xo!uE~G|&G6 D+QHxY literal 0 HcmV?d00001 diff --git a/doc/plotting.rst b/doc/plotting.rst index d762a6755..f7734ed3e 100644 --- a/doc/plotting.rst +++ b/doc/plotting.rst @@ -6,19 +6,40 @@ Plotting data The Python Control Toolbox contains a number of functions for plotting input/output responses in the time and frequency domain, root locus -diagrams, and other standard charts used in control system analysis. While -some legacy functions do both analysis and plotting, the standard pattern -used in the toolbox is to provide a function that performs the basic -computation (e.g., time or frequency response) and returns and object -representing the output data. A separate plotting function, typically -ending in `_plot` is then used to plot the data. The plotting function is -also available via the `plot()` method of the analysis object, allowing the -following type of calls:: +diagrams, and other standard charts used in control system analysis, for +example:: + + bode_plot(sys) + nyquist_plot([sys1, sys2]) + +.. root_locus_plot(sys) # not yet implemented + +While plotting functions can be called directly, the standard pattern used +in the toolbox is to provide a function that performs the basic computation +or analysis (e.g., computation of the time or frequency response) and +returns and object representing the output data. A separate plotting +function, typically ending in `_plot` is then used to plot the data, +resulting in the following standard pattern:: + + response = nyquist_response([sys1, sys2]) + count = response.count # number of encirclements of -1 + lines = nyquist_plot(response) # Nyquist plot + +The returned value `lines` provides access to the individual lines in the +generated plot, allowing various aspects of the plot to be modified to suit +specific needs. + +The plotting function is also available via the `plot()` method of the +analysis object, allowing the following type of calls:: step_response(sys).plot() - frequency_response(sys).plot() # implementation pending - nyquist_curve(sys).plot() # implementation pending - rootlocus_curve(sys).plot() # implementation pending + frequency_response(sys).plot() + nyquist_response(sys).plot() + rootlocus_response(sys).plot() # implementation pending + +The remainder of this chapter provides additional documentation on how +these response and plotting functions can be customized. + Time response data ================== @@ -36,7 +57,7 @@ response for a two-input, two-output can be plotted using the commands:: sys_mimo = ct.tf2ss( [[[1], [0.1]], [[0.2], [1]]], - [[[1, 0.6, 1], [1, 1, 1]], [[1, 0.4, 1], [1, 2, 1]]], name="MIMO") + [[[1, 0.6, 1], [1, 1, 1]], [[1, 0.4, 1], [1, 2, 1]]], name="sys_mimo") response = step_response(sys) response.plot() @@ -70,7 +91,7 @@ following plot:: ct.step_response(sys_mimo).plot( plot_inputs=True, overlay_signals=True, - title="Step response for 2x2 MIMO system " + + title="Step response for 2x2 MIMO system " + "[plot_inputs, overlay_signals]") .. image:: timeplot-mimo_step-pi_cs.png @@ -91,7 +112,7 @@ keyword:: legend_map=np.array([['lower right'], ['lower right']]), title="I/O response for 2x2 MIMO system " + "[plot_inputs='overlay', legend_map]") - + .. image:: timeplot-mimo_ioresp-ov_lm.png Another option that is available is to use the `transpose` keyword so that @@ -110,7 +131,7 @@ following figure:: transpose=True, title="I/O responses for 2x2 MIMO system, multiple traces " "[transpose]") - + .. image:: timeplot-mimo_ioresp-mt_tr.png This figure also illustrates the ability to create "multi-trace" plots @@ -131,12 +152,128 @@ and styles for various signals and traces:: .. image:: timeplot-mimo_step-linestyle.png +Frequency response data +======================= + +Linear time invariant (LTI) systems can be analyzed in terms of their +frequency response and python-control provides a variety of tools for +carrying out frequency response analysis. The most basic of these is +the :func:`~control.frequency_response` function, which will compute +the frequency response for one or more linear systems:: + + sys1 = ct.tf([1], [1, 2, 1], name='sys1') + sys2 = ct.tf([1, 0.2], [1, 1, 3, 1, 1], name='sys2') + response = ct.frequency_response([sys1, sys2]) + +A Bode plot provide a graphical view of the response an LTI system and can +be generated using the :func:`~control.bode_plot` function:: + + ct.bode_plot(response, initial_phase=0) + +.. image:: freqplot-siso_bode-default.png + +Computing the response for multiple systems at the same time yields a +common frequency range that covers the features of all listed systems. + +Bode plots can also be created directly using the +:meth:`~control.FrequencyResponseData.plot` method:: + + sys_mimo = ct.tf( + [[[1], [0.1]], [[0.2], [1]]], + [[[1, 0.6, 1], [1, 1, 1]], [[1, 0.4, 1], [1, 2, 1]]], name="sys_mimo") + ct.frequency_response(sys_mimo).plot() + +.. image:: freqplot-mimo_bode-default.png + +A variety of options are available for customizing Bode plots, for +example allowing the display of the phase to be turned off or +overlaying the inputs or outputs:: + + ct.frequency_response(sys_mimo).plot( + plot_phase=False, overlay_inputs=True, overlay_outputs=True) + +.. image:: freqplot-mimo_bode-magonly.png + +The :func:`~ct.singular_values_response` function can be used to +generate Bode plots that show the singular values of a transfer +function:: + + ct.singular_values_response(sys_mimo).plot() + +.. image:: freqplot-mimo_svplot-default.png + +Another response function that can be used to generate Bode plots is +the :func:`~ct.gangof4` function, which computes the four primary +sensitivity functions for a feedback control system in standard form:: + + proc = ct.tf([1], [1, 1, 1], name="process") + ctrl = ct.tf([100], [1, 5], name="control") + response = rect.gangof4_response(proc, ctrl) + ct.bode_plot(response) # or response.plot() + +.. image:: freqplot-gangof4.png + + +Response and plotting functions +=============================== + +Response functions +------------------ + +Response functions take a system or list of systems and return a response +object that can be used to retrieve information about the system (e.g., the +number of encirclements for a Nyquist plot) as well as plotting (via the +`plot` method). + +.. autosummary:: + :toctree: generated/ + + ~control.describing_function_response + ~control.frequency_response + ~control.forced_response + ~control.gangof4_response + ~control.impulse_response + ~control.initial_response + ~control.input_output_response + ~control.nyquist_response + ~control.singular_values_response + ~control.step_response + Plotting functions -================== +------------------ .. autosummary:: :toctree: generated/ + ~control.bode_plot + ~control.describing_function_plot + ~control.nyquist_plot + ~control.singular_values_plot ~control.time_response_plot + + +Utility functions +----------------- + +These additional functions can be used to manipulate response data or +returned values from plotting routines. + +.. autosummary:: + :toctree: generated/ + ~control.combine_time_responses ~control.get_plot_axes + + +Response classes +---------------- + +The following classes are used in generating response data. + +.. autosummary:: + :toctree: generated/ + + ~control.DescribingFunctionResponse + ~control.FrequencyResponseData + ~control.NyquistResponseData + ~control.TimeResponseData diff --git a/examples/mrac_siso_lyapunov.py b/examples/mrac_siso_lyapunov.py index 00dbf63aa..60550a8d9 100644 --- a/examples/mrac_siso_lyapunov.py +++ b/examples/mrac_siso_lyapunov.py @@ -12,6 +12,7 @@ import numpy as np import scipy.signal as signal import matplotlib.pyplot as plt +import os import control as ct @@ -154,7 +155,6 @@ def adaptive_controller_output(_t, xc, uc, params): plt.plot(tout3, yout3[1,:], label=r'$u_{\gamma = 5.0}$') plt.legend(loc=4, fontsize=14) plt.title(r'control $u$') -plt.show() plt.figure(figsize=(16,8)) plt.subplot(2,1,1) @@ -171,4 +171,5 @@ def adaptive_controller_output(_t, xc, uc, params): plt.hlines(kx_star, 0, Tend, label=r'$k_x^{\ast}$', color='black', linestyle='--') plt.legend(loc=4, fontsize=14) plt.title(r'control gain $k_x$ (feedback)') -plt.show() +if 'PYCONTROL_TEST_EXAMPLES' not in os.environ: + plt.show() diff --git a/examples/mrac_siso_mit.py b/examples/mrac_siso_mit.py index 1f87dd5f6..f901478cb 100644 --- a/examples/mrac_siso_mit.py +++ b/examples/mrac_siso_mit.py @@ -12,6 +12,7 @@ import numpy as np import scipy.signal as signal import matplotlib.pyplot as plt +import os import control as ct @@ -162,7 +163,6 @@ def adaptive_controller_output(t, xc, uc, params): plt.plot(tout3, yout3[1,:], label=r'$u_{\gamma = 5.0}$') plt.legend(loc=4, fontsize=14) plt.title(r'control $u$') -plt.show() plt.figure(figsize=(16,8)) plt.subplot(2,1,1) @@ -179,4 +179,6 @@ def adaptive_controller_output(t, xc, uc, params): plt.hlines(kx_star, 0, Tend, label=r'$k_x^{\ast}$', color='black', linestyle='--') plt.legend(loc=4, fontsize=14) plt.title(r'control gain $k_x$ (feedback)') -plt.show() \ No newline at end of file + +if 'PYCONTROL_TEST_EXAMPLES' not in os.environ: + plt.show() diff --git a/examples/pvtol-nested-ss.py b/examples/pvtol-nested-ss.py index 1af49e425..f53ac70f1 100644 --- a/examples/pvtol-nested-ss.py +++ b/examples/pvtol-nested-ss.py @@ -12,6 +12,8 @@ import matplotlib.pyplot as plt # MATLAB plotting functions from control.matlab import * # MATLAB-like functions import numpy as np +import math +import control as ct # System parameters m = 4 # mass of aircraft @@ -73,7 +75,6 @@ plt.figure(4) plt.clf() -plt.subplot(221) bode(Hi) # Now design the lateral control system @@ -87,7 +88,7 @@ Lo = -m*g*Po*Co plt.figure(5) -bode(Lo) # margin(Lo) +bode(Lo, display_margins=True) # margin(Lo) # Finally compute the real outer-loop loop gain + responses L = Co*Hi*Po @@ -100,48 +101,17 @@ plt.figure(6) plt.clf() -bode(L, logspace(-4, 3)) +out = ct.bode(L, logspace(-4, 3), initial_phase=-math.pi/2) +axs = ct.get_plot_axes(out) # Add crossover line to magnitude plot -for ax in plt.gcf().axes: - if ax.get_label() == 'control-bode-magnitude': - break -ax.semilogx([1e-4, 1e3], 20*np.log10([1, 1]), 'k-') - -# Re-plot phase starting at -90 degrees -mag, phase, w = freqresp(L, logspace(-4, 3)) -phase = phase - 360 - -for ax in plt.gcf().axes: - if ax.get_label() == 'control-bode-phase': - break -ax.semilogx([1e-4, 1e3], [-180, -180], 'k-') -ax.semilogx(w, np.squeeze(phase), 'b-') -ax.axis([1e-4, 1e3, -360, 0]) -plt.xlabel('Frequency [deg]') -plt.ylabel('Phase [deg]') -# plt.set(gca, 'YTick', [-360, -270, -180, -90, 0]) -# plt.set(gca, 'XTick', [10^-4, 10^-2, 1, 100]) +axs[0, 0].semilogx([1e-4, 1e3], 20*np.log10([1, 1]), 'k-') # # Nyquist plot for complete design # plt.figure(7) -plt.clf() -plt.axis([-700, 5300, -3000, 3000]) -nyquist(L, (0.0001, 1000)) -plt.axis([-700, 5300, -3000, 3000]) - -# Add a box in the region we are going to expand -plt.plot([-400, -400, 200, 200, -400], [-100, 100, 100, -100, -100], 'r-') - -# Expanded region -plt.figure(8) -plt.clf() -plt.subplot(231) -plt.axis([-10, 5, -20, 20]) nyquist(L) -plt.axis([-10, 5, -20, 20]) # set up the color color = 'b' @@ -163,10 +133,11 @@ plt.plot(Tvec.T, Yvec.T) #TODO: PZmap for statespace systems has not yet been implemented. -plt.figure(10) -plt.clf() +# plt.figure(10) +# plt.clf() # P, Z = pzmap(T, Plot=True) # print("Closed loop poles and zeros: ", P, Z) +# plt.suptitle("This figure intentionally blank") # Gang of Four plt.figure(11) From c3cc5047e167af8883401388b22835e78db53603 Mon Sep 17 00:00:00 2001 From: Richard Murray Date: Fri, 21 Jul 2023 22:09:03 -0700 Subject: [PATCH 14/17] updated unit tests --- control/freqplot.py | 2 +- control/tests/freqplot_test.py | 109 ++++++++++++++++++++++++++++----- control/tests/matlab_test.py | 6 ++ 3 files changed, 99 insertions(+), 18 deletions(-) diff --git a/control/freqplot.py b/control/freqplot.py index 89294a40a..0e75330d8 100644 --- a/control/freqplot.py +++ b/control/freqplot.py @@ -1185,7 +1185,7 @@ def plot(self, *args, **kwargs): class NyquistResponseList(list): def plot(self, *args, **kwargs): - nyquist_plot(self, *args, **kwargs) + return nyquist_plot(self, *args, **kwargs) def nyquist_response( diff --git a/control/tests/freqplot_test.py b/control/tests/freqplot_test.py index 7c65d269e..5120ca839 100644 --- a/control/tests/freqplot_test.py +++ b/control/tests/freqplot_test.py @@ -31,7 +31,7 @@ @pytest.mark.parametrize( "sys", [ ct.tf([1], [1, 2, 1], name='System 1'), # SISO - manual_response, # simple MIMO + manual_response, # simple MIMO ]) # @pytest.mark.parametrize("pltmag", [True, False]) # @pytest.mark.parametrize("pltphs", [True, False]) @@ -40,29 +40,30 @@ # @pytest.mark.parametrize("shrfrq", ['col', 'all', False, None]) # @pytest.mark.parametrize("secsys", [False, True]) @pytest.mark.parametrize( # combinatorial-style test (faster) - "pltmag, pltphs, shrmag, shrphs, shrfrq, secsys", - [(True, True, None, None, None, False), - (True, False, None, None, None, False), - (False, True, None, None, None, False), - (True, True, None, None, None, True), - (True, True, 'row', 'row', 'col', False), - (True, True, 'row', 'row', 'all', True), - (True, True, 'all', 'row', None, False), - (True, True, 'row', 'all', None, True), - (True, True, 'none', 'none', None, True), - (True, False, 'all', 'row', None, False), - (True, True, True, 'row', None, True), - (True, True, None, 'row', True, False), - (True, True, 'row', None, None, True), + "pltmag, pltphs, shrmag, shrphs, shrfrq, ovlout, ovlinp, secsys", + [(True, True, None, None, None, False, False, False), + (True, False, None, None, None, True, False, False), + (False, True, None, None, None, False, True, False), + (True, True, None, None, None, False, False, True), + (True, True, 'row', 'row', 'col', False, False, False), + (True, True, 'row', 'row', 'all', False, False, True), + (True, True, 'all', 'row', None, False, False, False), + (True, True, 'row', 'all', None, False, False, True), + (True, True, 'none', 'none', None, False, False, True), + (True, False, 'all', 'row', None, False, False, False), + (True, True, True, 'row', None, False, False, True), + (True, True, None, 'row', True, False, False, False), + (True, True, 'row', None, None, False, False, True), ]) def test_response_plots( - sys, pltmag, pltphs, shrmag, shrphs, shrfrq, secsys, clear=True): + sys, pltmag, pltphs, shrmag, shrphs, shrfrq, ovlout, ovlinp, + secsys, clear=True): # Save up the keyword arguments kwargs = dict( plot_magnitude=pltmag, plot_phase=pltphs, share_magnitude=shrmag, share_phase=shrphs, share_frequency=shrfrq, - # overlay_outputs=ovlout, overlay_inputs=ovlinp + overlay_outputs=ovlout, overlay_inputs=ovlinp ) # Create the response @@ -79,6 +80,16 @@ def test_response_plots( plt.figure() out = response.plot(**kwargs) + # Check the shape + if ovlout and ovlinp: + assert out.shape == (pltmag + pltphs, 1) + elif ovlout: + assert out.shape == (pltmag + pltphs, sys.ninputs) + elif ovlinp: + assert out.shape == (sys.noutputs * (pltmag + pltphs), 1) + else: + assert out.shape == (sys.noutputs * (pltmag + pltphs), sys.ninputs) + # Make sure all of the outputs are of the right type nlines_plotted = 0 for ax_lines in np.nditer(out, flags=["refs_ok"]): @@ -198,12 +209,24 @@ def test_first_arg_listable(response_cmd, return_type): result = response_cmd(sys) assert isinstance(result, return_type) + # Save the results from a single plot + lines_single = result.plot() + # If we pass a list of systems, we should get back a list result = response_cmd([sys, sys, sys]) assert isinstance(result, list) assert len(result) == 3 assert all([isinstance(item, return_type) for item in result]) + # Make sure that plot works + lines_list = result.plot() + if response_cmd == ct.frequency_response: + assert lines_list.shape == lines_single.shape + assert len(lines_list.reshape(-1)[0]) == \ + 3 * len(lines_single.reshape(-1)[0]) + else: + assert lines_list.shape[0] == 3 * lines_single.shape[0] + # If we pass a singleton list, we should get back a list result = response_cmd([sys]) assert isinstance(result, list) @@ -211,6 +234,58 @@ def test_first_arg_listable(response_cmd, return_type): assert isinstance(result[0], return_type) +def test_bode_share_options(): + # Default sharing should share along rows and cols for mag and phase + lines = ct.bode_plot(manual_response) + axs = ct.get_plot_axes(lines) + for i in range(axs.shape[0]): + for j in range(axs.shape[1]): + # Share y limits along rows + assert axs[i, j].get_ylim() == axs[i, 0].get_ylim() + + # Share x limits along columns + assert axs[i, j].get_xlim() == axs[-1, j].get_xlim() + + # Sharing along y axis for mag but not phase + plt.figure() + lines = ct.bode_plot(manual_response, share_phase='none') + axs = ct.get_plot_axes(lines) + for i in range(int(axs.shape[0] / 2)): + for j in range(axs.shape[1]): + if i != 0: + # Different rows are different + assert axs[i*2 + 1, 0].get_ylim() != axs[1, 0].get_ylim() + elif j != 0: + # Different columns are different + assert axs[i*2 + 1, j].get_ylim() != axs[i*2 + 1, 0].get_ylim() + + # Turn off sharing for magnitude and phase + plt.figure() + lines = ct.bode_plot(manual_response, sharey='none') + axs = ct.get_plot_axes(lines) + for i in range(int(axs.shape[0] / 2)): + for j in range(axs.shape[1]): + if i != 0: + # Different rows are different + assert axs[i*2, 0].get_ylim() != axs[0, 0].get_ylim() + assert axs[i*2 + 1, 0].get_ylim() != axs[1, 0].get_ylim() + elif j != 0: + # Different columns are different + assert axs[i*2, j].get_ylim() != axs[i*2, 0].get_ylim() + assert axs[i*2 + 1, j].get_ylim() != axs[i*2 + 1, 0].get_ylim() + + # Turn off sharing in x axes + plt.figure() + lines = ct.bode_plot(manual_response, sharex='none') + # TODO: figure out what to check + + +def test_bode_errors(): + # Turning off both magnitude and phase + with pytest.raises(ValueError, match="no data to plot"): + ct.bode_plot(manual_response, plot_magnitude=False, plot_phase=False) + + if __name__ == "__main__": # # Interactive mode: generate plots for manual viewing diff --git a/control/tests/matlab_test.py b/control/tests/matlab_test.py index 9ba793f70..e01abcca1 100644 --- a/control/tests/matlab_test.py +++ b/control/tests/matlab_test.py @@ -415,6 +415,12 @@ def testBode(self, siso, mplcleanup): # Not yet implemented # bode(siso.ss1, '-', siso.tf1, 'b--', siso.tf2, 'k.') + # Pass frequency range as a tuple + mag, phase, freq = bode(siso.ss1, (0.2e-2, 0.2e2)) + assert np.isclose(min(freq), 0.2e-2) + assert np.isclose(max(freq), 0.2e2) + assert len(freq) > 2 + @pytest.mark.parametrize("subsys", ["ss1", "tf1", "tf2"]) def testRlocus(self, siso, subsys, mplcleanup): """Call rlocus()""" From 33ca68b672c63ef938e5c0dc06ebabc9b5630f77 Mon Sep 17 00:00:00 2001 From: Richard Murray Date: Sat, 22 Jul 2023 09:18:27 -0700 Subject: [PATCH 15/17] refactoring of nichols into response/plot + updated unit tests, examples --- LICENSE | 1 + control/frdata.py | 3 + control/freqplot.py | 138 ++++++++------------------ control/nichols.py | 133 ++++++++++++++----------- control/tests/freqplot_test.py | 25 ++++- control/tests/kwargs_test.py | 4 + doc/freqplot-siso_bode-default.png | Bin 46705 -> 46693 bytes doc/freqplot-siso_nichols-default.png | Bin 0 -> 69964 bytes doc/plotting.rst | 10 +- 9 files changed, 153 insertions(+), 161 deletions(-) create mode 100644 doc/freqplot-siso_nichols-default.png diff --git a/LICENSE b/LICENSE index 6b6706ca6..5c84d3dcd 100644 --- a/LICENSE +++ b/LICENSE @@ -1,4 +1,5 @@ Copyright (c) 2009-2016 by California Institute of Technology +Copyright (c) 2016-2023 by python-control developers All rights reserved. Redistribution and use in source and binary forms, with or without diff --git a/control/frdata.py b/control/frdata.py index c677dd7f7..e0f7fdcc6 100644 --- a/control/frdata.py +++ b/control/frdata.py @@ -667,12 +667,15 @@ def plot(self, plot_type=None, *args, **kwargs): """ from .freqplot import bode_plot, singular_values_plot + from .nichols import nichols_plot if plot_type is None: plot_type = self.plot_type if plot_type == 'bode': return bode_plot(self, *args, **kwargs) + elif plot_type == 'nichols': + return nichols_plot(self, *args, **kwargs) elif plot_type == 'svplot': return singular_values_plot(self, *args, **kwargs) else: diff --git a/control/freqplot.py b/control/freqplot.py index 0e75330d8..0a8522437 100644 --- a/control/freqplot.py +++ b/control/freqplot.py @@ -1,67 +1,21 @@ # freqplot.py - frequency domain plots for control systems # -# Author: Richard M. Murray +# Initial author: Richard M. Murray # Date: 24 May 09 # -# Functionality to add -# [ ] Get rid of this long header (need some common, documented convention) -# [x] Add mechanisms for storing/plotting margins? (currently forces FRD) +# This file contains some standard control system plots: Bode plots, +# Nyquist plots and other frequency response plots. The code for Nichols +# charts is in nichols.py. The code for pole-zero diagrams is in pzmap.py +# and rlocus.py. +# +# Functionality to add/check (Jul 2023, working list) # [?] Allow line colors/styles to be set in plot() command (also time plots) -# [x] Allow bode or nyquist style plots from plot() -# [i] Allow nyquist_response() to generate the response curve (?) -# [i] Allow MIMO frequency plots (w/ mag/phase subplots a la MATLAB) -# [i] Update sisotool to use ax= -# [i] Create __main__ in freqplot_test to view results (a la timeplot_test) # [ ] Get sisotool working in iPython and document how to make it work -# [i] Allow share_magnitude, share_phase, share_frequency keywords for units -# [i] Re-implement including of gain/phase margin in the title (?) -# [i] Change gangof4 to use bode_plot(plot_phase=False) w/ proper labels # [ ] Allow use of subplot labels instead of output/input subtitles -# [i] Add line labels to gangof4 [done by via bode_plot()] # [i] Allow frequency range to be overridden in bode_plot # [i] Unit tests for discrete time systems with different sample times -# [c] Check examples/bode-and-nyquist-plots.ipynb for differences # [ ] Add unit tests for ct.config.defaults['freqplot_number_of_samples'] -# -# This file contains some standard control system plots: Bode plots, -# Nyquist plots and other frequency response plots. The code for Nichols -# charts is in nichols.py. The code for pole-zero diagrams is in pzmap.py -# and rlocus.py. -# -# Copyright (c) 2010 by California Institute of Technology -# All rights reserved. -# -# Redistribution and use in source and binary forms, with or without -# modification, are permitted provided that the following conditions -# are met: -# -# 1. Redistributions of source code must retain the above copyright -# notice, this list of conditions and the following disclaimer. -# -# 2. Redistributions in binary form must reproduce the above copyright -# notice, this list of conditions and the following disclaimer in the -# documentation and/or other materials provided with the distribution. -# -# 3. Neither the name of the California Institute of Technology nor -# the names of its contributors may be used to endorse or promote -# products derived from this software without specific prior -# written permission. -# -# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS -# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT -# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS -# FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL CALTECH -# OR THE CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, -# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT -# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF -# USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND -# ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, -# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT -# OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF -# SUCH DAMAGE. -# - import numpy as np import matplotlib as mpl import matplotlib.pyplot as plt @@ -128,15 +82,12 @@ def plot(self, *args, plot_type=None, **kwargs): if plot_type is not None and response.plot_type != plot_type: raise TypeError( "inconsistent plot_types in data; set plot_type " - "to 'bode' or 'svplot'") + "to 'bode', 'nichols', or 'svplot'") plot_type = response.plot_type - if plot_type == 'bode': - return bode_plot(self, *args, **kwargs) - elif plot_type == 'svplot': - return singular_values_plot(self, *args, **kwargs) - else: - raise ValueError(f"unknown plot type '{plot_type}'") + # Use FRD plot method, which can handle lists via plot functions + return FrequencyResponseData.plot( + self, plot_type=plot_type, *args, **kwargs) # # Bode plot @@ -1936,23 +1887,7 @@ def _parse_linestyle(style_name, allow_false=False): ax.grid(color="lightgray") # List of systems that are included in this plot - labels, lines = [], [] - last_color, counter = None, 0 # label unknown systems - for i, line in enumerate(ax.get_lines()): - label = line.get_label() - if label.startswith("Unknown"): - label = f"Unknown-{counter}" - if last_color is None: - last_color = line.get_color() - elif last_color != line.get_color(): - counter += 1 - last_color = line.get_color() - elif label[0] == '_': - continue - - if label not in labels: - lines.append(line) - labels.append(label) + lines, labels = _get_line_labels(ax) # Add legend if there is more than one system plotted if len(labels) > 1: @@ -2279,6 +2214,9 @@ def singular_values_plot( (legacy) If given, `singular_values_plot` returns the legacy return values of magnitude, phase, and frequency. If False, just return the values with no plot. + legend_loc : str, optional + For plots with multiple lines, a legend will be included in the + given location. Default is 'center right'. Use False to supress. **kwargs : :func:`matplotlib.pyplot.plot` keyword properties, optional Additional keywords passed to `matplotlib` to specify line properties. @@ -2400,8 +2338,8 @@ def singular_values_plot( if dB: with plt.rc_context(freqplot_rcParams): out[idx_sys] = ax_sigma.semilogx( - omega_plot, 20 * np.log10(sigma_plot), color=color, - label=sysname, *fmt, **kwargs) + omega_plot, 20 * np.log10(sigma_plot), *fmt, color=color, + label=sysname, **kwargs) else: with plt.rc_context(freqplot_rcParams): out[idx_sys] = ax_sigma.loglog( @@ -2422,26 +2360,10 @@ def singular_values_plot( ax_sigma.set_xlabel("Frequency [Hz]" if Hz else "Frequency [rad/sec]") # List of systems that are included in this plot - labels, lines = [], [] - last_color, counter = None, 0 # label unknown systems - for i, line in enumerate(ax_sigma.get_lines()): - label = line.get_label() - if label.startswith("Unknown"): - label = f"Unknown-{counter}" - if last_color is None: - last_color = line.get_color() - elif last_color != line.get_color(): - counter += 1 - last_color = line.get_color() - elif label[0] == '_': - continue - - if label not in labels: - lines.append(line) - labels.append(label) + lines, labels = _get_line_labels(ax_sigma) # Add legend if there is more than one system plotted - if len(labels) > 1: + if len(labels) > 1 and legend_loc is not False: with plt.rc_context(freqplot_rcParams): ax_sigma.legend(lines, labels, loc=legend_loc) @@ -2649,6 +2571,28 @@ def _default_frequency_range(syslist, Hz=None, number_of_samples=None, return omega +# Get labels for all lines in an axes +def _get_line_labels(ax, use_color=True): + labels, lines = [], [] + last_color, counter = None, 0 # label unknown systems + for i, line in enumerate(ax.get_lines()): + label = line.get_label() + if use_color and label.startswith("Unknown"): + label = f"Unknown-{counter}" + if last_color is None: + last_color = line.get_color() + elif last_color != line.get_color(): + counter += 1 + last_color = line.get_color() + elif label[0] == '_': + continue + + if label not in labels: + lines.append(line) + labels.append(label) + + return lines, labels + # # Utility functions to create nice looking labels (KLD 5/23/11) # diff --git a/control/nichols.py b/control/nichols.py index 1f83ae407..1a5043cd4 100644 --- a/control/nichols.py +++ b/control/nichols.py @@ -1,3 +1,8 @@ +# nichols.py - Nichols plot +# +# Contributed by Allan McInnes +# + """nichols.py Functions for plotting Black-Nichols charts. @@ -8,53 +13,16 @@ nichols.nichols_grid """ -# nichols.py - Nichols plot -# -# Contributed by Allan McInnes -# -# This file contains some standard control system plots: Bode plots, -# Nyquist plots, Nichols plots and pole-zero diagrams -# -# Copyright (c) 2010 by California Institute of Technology -# All rights reserved. -# -# Redistribution and use in source and binary forms, with or without -# modification, are permitted provided that the following conditions -# are met: -# -# 1. Redistributions of source code must retain the above copyright -# notice, this list of conditions and the following disclaimer. -# -# 2. Redistributions in binary form must reproduce the above copyright -# notice, this list of conditions and the following disclaimer in the -# documentation and/or other materials provided with the distribution. -# -# 3. Neither the name of the California Institute of Technology nor -# the names of its contributors may be used to endorse or promote -# products derived from this software without specific prior -# written permission. -# -# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS -# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT -# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS -# FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL CALTECH -# OR THE CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, -# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT -# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF -# USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND -# ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, -# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT -# OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF -# SUCH DAMAGE. -# -# $Id: freqplot.py 139 2011-03-30 16:19:59Z murrayrm $ - import numpy as np import matplotlib.pyplot as plt import matplotlib.transforms from .ctrlutil import unwrap -from .freqplot import _default_frequency_range +from .freqplot import _default_frequency_range, _freqplot_defaults, \ + _get_line_labels +from .lti import frequency_response +from .statesp import StateSpace +from .xferfcn import TransferFunction from . import config __all__ = ['nichols_plot', 'nichols', 'nichols_grid'] @@ -65,53 +33,81 @@ } -def nichols_plot(sys_list, omega=None, grid=None): +def nichols_plot( + data, omega=None, *fmt, grid=None, title=None, + legend_loc='upper left', **kwargs): """Nichols plot for a system. Plots a Nichols plot for the system over a (optional) frequency range. Parameters ---------- - sys_list : list of LTI, or LTI - List of linear input/output systems (single system is OK) + data : list of `FrequencyResponseData` or `LTI` + List of LTI systems or :class:`FrequencyResponseData` objects. A + single system or frequency response can also be passed. omega : array_like Range of frequencies (list or bounds) in rad/sec + *fmt : :func:`matplotlib.pyplot.plot` format string, optional + Passed to `matplotlib` as the format string for all lines in the plot. + The `omega` parameter must be present (use omega=None if needed). grid : boolean, optional True if the plot should include a Nichols-chart grid. Default is True. + legend_loc : str, optional + For plots with multiple lines, a legend will be included in the + given location. Default is 'upper left'. Use False to supress. + **kwargs : :func:`matplotlib.pyplot.plot` keyword properties, optional + Additional keywords passed to `matplotlib` to specify line properties. Returns ------- - None + lines : array of Line2D + 1-D array of Line2D objects. The size of the array matches + the number of systems and the value of the array is a list of + Line2D objects for that system. """ # Get parameter values grid = config._get_param('nichols', 'grid', grid, True) - + freqplot_rcParams = config._get_param( + 'freqplot', 'rcParams', kwargs, _freqplot_defaults, pop=True) # If argument was a singleton, turn it into a list - if not getattr(sys_list, '__iter__', False): - sys_list = (sys_list,) + if not isinstance(data, (tuple, list)): + data = [data] + + # If we were passed a list of systems, convert to data + if all([isinstance( + sys, (StateSpace, TransferFunction)) for sys in data]): + data = frequency_response(data, omega=omega) - # Select a default range if none is provided - if omega is None: - omega = _default_frequency_range(sys_list) + # Make sure that all systems are SISO + if any([resp.ninputs > 1 or resp.noutputs > 1 for resp in data]): + raise NotImplementedError("MIMO Nichols plots not implemented") - for sys in sys_list: + # Create a list of lines for the output + out = np.empty(len(data), dtype=object) + + for idx, response in enumerate(data): # 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) + mag = np.squeeze(response.magnitude) + phase = np.squeeze(response.phase) + omega = response.omega # Convert to Nichols-plot format (phase in degrees, # and magnitude in dB) x = unwrap(np.degrees(phase), 360) y = 20*np.log10(mag) + # Decide on the system name + sysname = response.sysname if response.sysname is not None \ + else f"Unknown-{idx_sys}" + # Generate the plot - plt.plot(x, y) + with plt.rc_context(freqplot_rcParams): + out[idx] = plt.plot(x, y, *fmt, label=sysname, **kwargs) - plt.xlabel('Phase (deg)') - plt.ylabel('Magnitude (dB)') - plt.title('Nichols Plot') + # Label the plot axes + plt.xlabel('Phase [deg]') + plt.ylabel('Magnitude [dB]') # Mark the -180 point plt.plot([-180], [0], 'r+') @@ -120,6 +116,23 @@ def nichols_plot(sys_list, omega=None, grid=None): if grid: nichols_grid() + # List of systems that are included in this plot + ax_nichols = plt.gca() + lines, labels = _get_line_labels(ax_nichols) + + # Add legend if there is more than one system plotted + if len(labels) > 1 and legend_loc is not False: + with plt.rc_context(freqplot_rcParams): + ax_nichols.legend(lines, labels, loc=legend_loc) + + # Add the title + if title is None: + title = "Nichols plot for " + ", ".join(labels) + with plt.rc_context(freqplot_rcParams): + plt.suptitle(title) + + return out + def _inner_extents(ax): # intersection of data and view extents diff --git a/control/tests/freqplot_test.py b/control/tests/freqplot_test.py index 5120ca839..f064b1315 100644 --- a/control/tests/freqplot_test.py +++ b/control/tests/freqplot_test.py @@ -56,8 +56,8 @@ (True, True, 'row', None, None, False, False, True), ]) def test_response_plots( - sys, pltmag, pltphs, shrmag, shrphs, shrfrq, ovlout, ovlinp, - secsys, clear=True): + sys, pltmag, pltphs, shrmag, shrphs, shrfrq, secsys, + ovlout, ovlinp, clear=True): # Save up the keyword arguments kwargs = dict( @@ -186,6 +186,12 @@ def test_basic_freq_plots(savefigs=False): if savefigs: plt.savefig('freqplot-mimo_svplot-default.png') + # Nichols chart + plt.figure() + ct.nichols_plot(response) + if savefigs: + plt.savefig('freqplot-siso_nichols-default.png') + def test_gangof4_plots(savefigs=False): proc = ct.tf([1], [1, 1, 1], name="process") @@ -280,6 +286,19 @@ def test_bode_share_options(): # TODO: figure out what to check +@pytest.mark.parametrize("plot_type", ['bode', 'svplot', 'nichols']) +def test_freqplot_plot_type(plot_type): + if plot_type == 'svplot': + response = ct.singular_values_response(ct.rss(2, 1, 1)) + else: + response = ct.frequency_response(ct.rss(2, 1, 1)) + lines = response.plot(plot_type=plot_type) + if plot_type == 'bode': + assert lines.shape == (2, 1) + else: + assert lines.shape == (1, ) + + def test_bode_errors(): # Turning off both magnitude and phase with pytest.raises(ValueError, match="no data to plot"): @@ -323,7 +342,7 @@ def test_bode_errors(): (sys_test, True, True, 'row', None, 'col', True), ] for args in test_cases: - test_response_plots(*args, clear=False) + test_response_plots(*args, ovlinp=False, ovlout=False, clear=False) # Define and run a selected set of interesting tests # TODO: TBD (see timeplot_test.py for format) diff --git a/control/tests/kwargs_test.py b/control/tests/kwargs_test.py index 22df360ac..0d13e6391 100644 --- a/control/tests/kwargs_test.py +++ b/control/tests/kwargs_test.py @@ -146,6 +146,8 @@ def test_unrecognized_kwargs(function, nsssys, ntfsys, moreargs, kwargs, (control.descfcn.saturation_nonlinearity(1), [1, 2, 3, 4]), {}), (control.gangof4, 2, (), {}), (control.gangof4_plot, 2, (), {}), + (control.nichols, 1, (), {}), + (control.nichols_plot, 1, (), {}), (control.nyquist, 1, (), {}), (control.nyquist_plot, 1, (), {}), (control.singular_values_plot, 1, (), {})] @@ -232,6 +234,8 @@ def test_response_plot_kwargs(data_fcn, plot_fcn, mimo): 'linearize': test_unrecognized_kwargs, 'lqe': test_unrecognized_kwargs, 'lqr': test_unrecognized_kwargs, + 'nichols_plot': test_matplotlib_kwargs, + 'nichols': test_matplotlib_kwargs, 'nlsys': test_unrecognized_kwargs, 'nyquist': test_matplotlib_kwargs, 'nyquist_response': test_response_plot_kwargs, diff --git a/doc/freqplot-siso_bode-default.png b/doc/freqplot-siso_bode-default.png index f07ee416446b13bbd3a394319dd1173cf1161ac3..924de66f44e7ddd74174751dc393ec18be25d4c6 100644 GIT binary patch literal 46693 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

Z*{xR8mBBsoF&7F31_m#9Y@n>7 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 A(*OVf literal 46705 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

Vp@epsA1nH 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 D+QHxY diff --git a/doc/freqplot-siso_nichols-default.png b/doc/freqplot-siso_nichols-default.png new file mode 100644 index 0000000000000000000000000000000000000000..687afdd51f3c9de67078c5e56af0ac939c1af046 GIT binary patch literal 69964 zcmdSBX*iZ`8#a0yLP(M%vjz!?LP%zkBqUSj%po($JSG`Rs7xUwA*9Jnk`&35BvXb+ zNFw9fubyvx>-&DJA8Xsz`muU?dmC=<>$=YKJdS-j?kFwI6I*E6Xh|f}mXm7AIwTT# z5Q#)KNJE9c5pVzU4gV+Qp<>{1_L7Z9mE1i{m8^M|(>iFKah<`%BJ8gvEtL zg?Q{dJY3wR4;^y)pDz%;RJdrAhbydvU(V|Kk@Mj;+qc1%@?Sy{+&&b&4)vxg$e(GPUp7rKaSX)ln7B3ld{j zPR|bw)`dzgPh@B62G&{jr+F~ZQ&F%+ z1h@Y3?~vdb6Orpy;FcIg#(Dl}bf8rf9T^(Cyq!teVjv--kk zUtfM3PmgwyyuLUP$UU&~b#ZomWo9I5e|l}EQl!hz7vo9)>$~Ko`wBQXIJk_bd5;Mt zyfQpWJ%0D@-C5j`#Poce>@tnnQvJ?* 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 O4~5SRd4~7=jsF5upT@)h literal 0 HcmV?d00001 diff --git a/doc/plotting.rst b/doc/plotting.rst index f7734ed3e..be7ae7a55 100644 --- a/doc/plotting.rst +++ b/doc/plotting.rst @@ -202,6 +202,14 @@ function:: .. image:: freqplot-mimo_svplot-default.png +Different types of plots can also be specified for a given frequency +response. For example, to plot the frequency response using a a Nichols +plot, use `plot_type='nichols'`:: + + response.plot(plot_type='nichols') + +.. image:: freqplot-siso_nichols-default.png + Another response function that can be used to generate Bode plots is the :func:`~ct.gangof4` function, which computes the four primary sensitivity functions for a feedback control system in standard form:: @@ -247,7 +255,7 @@ Plotting functions ~control.bode_plot ~control.describing_function_plot - ~control.nyquist_plot + ~control.nichols_plot ~control.singular_values_plot ~control.time_response_plot From 9e326dda314b7ee5fd37c0f27cbcf17cfa4341f2 Mon Sep 17 00:00:00 2001 From: Richard Murray Date: Sun, 23 Jul 2023 14:41:40 -0700 Subject: [PATCH 16/17] add frequency_limit and line style processing + unit tests, fixes --- control/freqplot.py | 162 ++++++++++++++++++++------------- control/sisotool.py | 2 +- control/tests/freqplot_test.py | 73 +++++++++++++-- control/tests/nyquist_test.py | 17 +++- 4 files changed, 179 insertions(+), 75 deletions(-) diff --git a/control/freqplot.py b/control/freqplot.py index 0a8522437..b7f80c64a 100644 --- a/control/freqplot.py +++ b/control/freqplot.py @@ -7,14 +7,6 @@ # Nyquist plots and other frequency response plots. The code for Nichols # charts is in nichols.py. The code for pole-zero diagrams is in pzmap.py # and rlocus.py. -# -# Functionality to add/check (Jul 2023, working list) -# [?] Allow line colors/styles to be set in plot() command (also time plots) -# [ ] Get sisotool working in iPython and document how to make it work -# [ ] Allow use of subplot labels instead of output/input subtitles -# [i] Allow frequency range to be overridden in bode_plot -# [i] Unit tests for discrete time systems with different sample times -# [ ] Add unit tests for ct.config.defaults['freqplot_number_of_samples'] import numpy as np import matplotlib as mpl @@ -103,7 +95,7 @@ def bode_plot( overlay_outputs=None, overlay_inputs=None, phase_label=None, magnitude_label=None, display_margins=None, margins_method='best', legend_map=None, legend_loc=None, - sharex=None, sharey=None, title=None, relabel=True, **kwargs): + sharex=None, sharey=None, title=None, **kwargs): """Bode plot for a system. Plot the magnitude and phase of the frequency response over a @@ -116,7 +108,8 @@ def bode_plot( single system or frequency response can also be passed. omega : array_like, optoinal List of frequencies in rad/sec over to plot over. If not specified, - this will be determined from the proporties of the systems. + this will be determined from the proporties of the systems. Ignored + if `data` is not a list of systems. *fmt : :func:`matplotlib.pyplot.plot` format string, optional Passed to `matplotlib` as the format string for all lines in the plot. The `omega` parameter must be present (use omega=None if needed). @@ -147,16 +140,6 @@ def bode_plot( Other Parameters ---------------- - plot : bool, optional - (legacy) If given, `bode_plot` returns the legacy return values - of magnitude, phase, and frequency. If False, just return the - values with no plot. - 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 - Number of samples to plot. Defaults to - config.defaults['freqplot.number_of_samples']. grid : bool If True, plot grid lines on gain and phase plots. Default is set by `config.defaults['freqplot.grid']`. @@ -166,6 +149,20 @@ def bode_plot( value specified. Units are in either degrees or radians, depending on the `deg` parameter. Default is -180 if wrap_phase is False, 0 if wrap_phase is True. + omega_limits : array_like of two values + Set limits for plotted frequency range. If Hz=True the limits + are in Hz otherwise in rad/s. + omega_num : int + Number of samples to use for the frequeny range. Defaults to + config.defaults['freqplot.number_of_samples']. Ignore if data is + not a list of systems. + plot : bool, optional + (legacy) If given, `bode_plot` returns the legacy return values + of magnitude, phase, and frequency. If False, just return the + values with no plot. + rcParams : dict + Override the default parameters used for generating plots. + Default is set up config.default['freqplot.rcParams']. wrap_phase : bool or float If wrap_phase is `False` (default), then the phase will be unwrapped so that it is continuously increasing or decreasing. If wrap_phase is @@ -220,7 +217,6 @@ def bode_plot( 'freqplot', 'wrap_phase', kwargs, _freqplot_defaults, pop=True) initial_phase = config._get_param( 'freqplot', 'initial_phase', kwargs, None, pop=True) - omega_num = config._get_param('freqplot', 'number_of_samples', omega_num) freqplot_rcParams = config._get_param( 'freqplot', 'rcParams', kwargs, _freqplot_defaults, pop=True) @@ -262,7 +258,7 @@ def bode_plot( data = [data] # - # Pre-process the data to be plotted (unwrap phase) + # Pre-process the data to be plotted (unwrap phase, limit frequencies) # # To maintain compatibility with legacy uses of bode_plot(), we do some # initial processing on the data, specifically phase unwrapping and @@ -277,6 +273,17 @@ def bode_plot( data = frequency_response( data, omega=omega, omega_limits=omega_limits, omega_num=omega_num, Hz=Hz) + else: + # Generate warnings if frequency keywords were given + if omega_num is not None: + warnings.warn("`omega_num` ignored when passed response data") + elif omega is not None: + warnings.warn("`omega` ignored when passed response data") + + # Check to make sure omega_limits is sensible + if omega_limits is not None and \ + (len(omega_limits) != 2 or omega_limits[1] <= omega_limits[0]): + raise ValueError(f"invalid limits: {omega_limits=}") # If plot_phase is not specified, check the data first, otherwise true if plot_phase is None: @@ -288,7 +295,6 @@ def bode_plot( mag_data, phase_data, omega_data = [], [], [] for response in data: - phase = response.phase.copy() noutputs, ninputs = response.noutputs, response.ninputs if initial_phase is None: @@ -306,9 +312,9 @@ def bode_plot( raise ValueError("initial_phase must be a number.") # Reshape the phase to allow standard indexing - phase = phase.reshape((noutputs, ninputs, -1)) + phase = response.phase.copy().reshape((noutputs, ninputs, -1)) - # Shift and wrap + # Shift and wrap the phase for i, j in itertools.product(range(noutputs), range(ninputs)): # Shift the phase if needed if abs(phase[i, j, 0] - initial_phase_value) > math.pi: @@ -641,7 +647,7 @@ def _make_line_label(response, output_index, input_index): # Get the (pre-processed) data in fully indexed form mag = mag_data[index].reshape((noutputs, ninputs, -1)) phase = phase_data[index].reshape((noutputs, ninputs, -1)) - omega_sys, sysname = response.omega, response.sysname + omega_sys, sysname = omega_data[index], response.sysname for i, j in itertools.product(range(noutputs), range(ninputs)): # Get the axes to use for magnitude and phase @@ -831,21 +837,17 @@ def _make_line_label(response, output_index, input_index): for i in range(noutputs): for j in range(ninputs): + # Utility function to generate phase labels def gen_zero_centered_series(val_min, val_max, period): v1 = np.ceil(val_min / period - 0.2) v2 = np.floor(val_max / period + 0.2) return np.arange(v1, v2 + 1) * period - # TODO: put Nyquist lines here? - - # TODO: what is going on here - # TODO: fix to use less dense labels, when needed - # TODO: make sure turning sharey on and off makes labels come/go + # Label the phase axes using multiples of 45 degrees if plot_phase: ax_phase = ax_array[phase_map[i, j]] # Set the labels - # TODO: tighten up code if deg: ylim = ax_phase.get_ylim() num = np.floor((ylim[1] - ylim[0]) / 45) @@ -877,6 +879,11 @@ def gen_zero_centered_series(val_min, val_max, period): if share_frequency in [True, 'all', 'col']: ax_array[i, j].tick_params(labelbottom=False) + # If specific omega_limits were given, use them + if omega_limits is not None: + for i, j in itertools.product(range(nrows), range(ncols)): + ax_array[i, j].set_xlim(omega_limits) + # # Update the plot title (= figure suptitle) # @@ -895,7 +902,7 @@ def gen_zero_centered_series(val_min, val_max, period): else: title = data[0].title - if fig is not None and title is not None: + if fig is not None and isinstance(title, str): # Get the current title, if it exists old_title = None if fig._suptitle is None else fig._suptitle._text new_title = title @@ -1294,11 +1301,11 @@ def nyquist_response( # Determine the contour used to evaluate the Nyquist curve if sys.isdtime(strict=True): # Restrict frequencies for discrete-time systems - nyquistfrq = math.pi / sys.dt + nyq_freq = 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 < nyq_freq], nyq_freq)) # Issue a warning if we are sampling above Nyquist if np.any(omega_sys * sys.dt > np.pi) and warn_nyquist: @@ -1817,7 +1824,7 @@ def _parse_linestyle(style_name, allow_false=False): x, y = resp.real.copy(), resp.imag.copy() x[reg_mask] *= (1 + curve_offset[reg_mask]) y[reg_mask] *= (1 + curve_offset[reg_mask]) - p = plt.plot(x, y, linestyle='None', color=c, **kwargs) + p = plt.plot(x, y, linestyle='None', color=c) # Add arrows ax = plt.gca() @@ -2210,10 +2217,6 @@ def singular_values_plot( Hz : bool If True, plot frequency in Hz (omega must be provided in rad/sec). Default value (False) set by config.defaults['freqplot.Hz']. - plot : bool, optional - (legacy) If given, `singular_values_plot` returns the legacy return - values of magnitude, phase, and frequency. If False, just return - the values with no plot. legend_loc : str, optional For plots with multiple lines, a legend will be included in the given location. Default is 'center right'. Use False to supress. @@ -2233,6 +2236,26 @@ def singular_values_plot( omega : ndarray (or list of ndarray if len(data) > 1)) If plot=False, frequency in rad/sec (deprecated). + Other Parameters + ---------------- + grid : bool + If True, plot grid lines on gain and phase plots. Default is set by + `config.defaults['freqplot.grid']`. + omega_limits : array_like of two values + Set limits for plotted frequency range. If Hz=True the limits + are in Hz otherwise in rad/s. + omega_num : int + Number of samples to use for the frequeny range. Defaults to + config.defaults['freqplot.number_of_samples']. Ignore if data is + not a list of systems. + plot : bool, optional + (legacy) If given, `singular_values_plot` returns the legacy return + values of magnitude, phase, and frequency. If False, just return + the values with no plot. + rcParams : dict + Override the default parameters used for generating plots. + Default is set up config.default['freqplot.rcParams']. + """ # Keyword processing dB = config._get_param( @@ -2241,7 +2264,6 @@ def singular_values_plot( 'freqplot', 'Hz', kwargs, _freqplot_defaults, pop=True) grid = config._get_param( 'freqplot', 'grid', kwargs, _freqplot_defaults, pop=True) - omega_num = config._get_param('freqplot', 'number_of_samples', omega_num) freqplot_rcParams = config._get_param( 'freqplot', 'rcParams', kwargs, _freqplot_defaults, pop=True) @@ -2255,6 +2277,17 @@ def singular_values_plot( data, omega=omega, omega_limits=omega_limits, omega_num=omega_num) else: + # Generate warnings if frequency keywords were given + if omega_num is not None: + warnings.warn("`omega_num` ignored when passed response data") + elif omega is not None: + warnings.warn("`omega` ignored when passed response data") + + # Check to make sure omega_limits is sensible + if omega_limits is not None and \ + (len(omega_limits) != 2 or omega_limits[1] <= omega_limits[0]): + raise ValueError(f"invalid limits: {omega_limits=}") + responses = data # Process (legacy) plot keyword @@ -2308,48 +2341,49 @@ def singular_values_plot( # Create a list of lines for the output out = np.empty(len(data), dtype=object) + # Plot the singular values for each response for idx_sys, response in enumerate(responses): sigma = sigmas[idx_sys].transpose() # frequency first for plotting - omega_sys = omegas[idx_sys] + omega = omegas[idx_sys] / (2 * math.pi) if Hz else omegas[idx_sys] + if response.isdtime(strict=True): - nyquistfrq = math.pi / response.dt + nyq_freq = (0.5/response.dt) if Hz else (math.pi/response.dt) else: - nyquistfrq = None + nyq_freq = None - color = color_cycle[(idx_sys + color_offset) % len(color_cycle)] - color = kwargs.pop('color', color) - - # TODO: copy from above - nyquistfrq_plot = None - if Hz: - omega_plot = omega_sys / (2. * math.pi) - if nyquistfrq: - nyquistfrq_plot = nyquistfrq / (2. * math.pi) + # See if the color was specified, otherwise rotate + if kwargs.get('color', None) or any( + [isinstance(arg, str) and + any([c in arg for c in "bgrcmykw#"]) for arg in fmt]): + color_arg = {} # color set by *fmt, **kwargs else: - omega_plot = omega_sys - if nyquistfrq: - nyquistfrq_plot = nyquistfrq - sigma_plot = sigma + color_arg = {'color': color_cycle[ + (idx_sys + color_offset) % len(color_cycle)]} # Decide on the system name sysname = response.sysname if response.sysname is not None \ else f"Unknown-{idx_sys}" + # Plot the data if dB: with plt.rc_context(freqplot_rcParams): out[idx_sys] = ax_sigma.semilogx( - omega_plot, 20 * np.log10(sigma_plot), *fmt, color=color, - label=sysname, **kwargs) + omega, 20 * np.log10(sigma), *fmt, + label=sysname, **color_arg, **kwargs) else: with plt.rc_context(freqplot_rcParams): out[idx_sys] = ax_sigma.loglog( - omega_plot, sigma_plot, color=color, label=sysname, - *fmt, **kwargs) + omega, sigma, label=sysname, *fmt, **color_arg, **kwargs) - if nyquistfrq_plot is not None: + # Plot the Nyquist frequency + if nyq_freq is not None: ax_sigma.axvline( - nyquistfrq_plot, color=color, linestyle='--', - label='_nyq_freq_' + sysname) + nyq_freq, linestyle='--', label='_nyq_freq_' + sysname, + **color_arg) + + # If specific omega_limits were given, use them + if omega_limits is not None: + ax_sigma.set_xlim(omega_limits) # Add a grid to the plot + labeling if grid: diff --git a/control/sisotool.py b/control/sisotool.py index f059c0af3..9af5268b9 100644 --- a/control/sisotool.py +++ b/control/sisotool.py @@ -149,7 +149,7 @@ def _SisotoolUpdate(sys, fig, K, bode_plot_params, tvect=None): # Update the bodeplot bode_plot_params['data'] = frequency_response(sys_loop*K.real) - bode_plot(**bode_plot_params) + bode_plot(**bode_plot_params, title=False) # Set the titles and labels ax_mag.set_title('Bode magnitude',fontsize = title_font_size) diff --git a/control/tests/freqplot_test.py b/control/tests/freqplot_test.py index f064b1315..5383f28a7 100644 --- a/control/tests/freqplot_test.py +++ b/control/tests/freqplot_test.py @@ -147,7 +147,28 @@ def test_manual_response_limits(): assert axs[0, 0].get_ylim() != axs[2, 0].get_ylim() assert axs[1, 0].get_ylim() != axs[3, 0].get_ylim() - # TODO: finish writing tests + +@pytest.mark.parametrize( + "plt_fcn", [ct.bode_plot, ct.nichols_plot, ct.singular_values_plot]) +def test_line_styles(plt_fcn): + # Define a couple of systems for testing + sys1 = ct.tf([1], [1, 2, 1], name='sys1') + sys2 = ct.tf([1, 0.2], [1, 1, 3, 1, 1], name='sys2') + sys3 = ct.tf([0.2, 0.1], [1, 0.1, 0.3, 0.1, 0.1], name='sys3') + + # Create a plot for the first system, with custom styles + lines_default = plt_fcn(sys1) + + # Now create a plot using *fmt customization + lines_fmt = plt_fcn(sys2, None, 'r--') + assert lines_fmt.reshape(-1)[0][0].get_color() == 'r' + assert lines_fmt.reshape(-1)[0][0].get_linestyle() == '--' + + # Add a third plot using keyword customization + lines_kwargs = plt_fcn(sys3, color='g', linestyle=':') + assert lines_kwargs.reshape(-1)[0][0].get_color() == 'g' + assert lines_kwargs.reshape(-1)[0][0].get_linestyle() == ':' + def test_basic_freq_plots(savefigs=False): # Basic SISO Bode plot @@ -203,6 +224,7 @@ def test_gangof4_plots(savefigs=False): if savefigs: plt.savefig('freqplot-gangof4.png') + @pytest.mark.parametrize("response_cmd, return_type", [ (ct.frequency_response, ct.FrequencyResponseData), (ct.nyquist_response, ct.freqplot.NyquistResponseData), @@ -298,11 +320,50 @@ def test_freqplot_plot_type(plot_type): else: assert lines.shape == (1, ) - -def test_bode_errors(): - # Turning off both magnitude and phase - with pytest.raises(ValueError, match="no data to plot"): - ct.bode_plot(manual_response, plot_magnitude=False, plot_phase=False) +@pytest.mark.parametrize("plt_fcn", [ct.bode_plot, ct.singular_values_plot]) +def test_freqplot_omega_limits(plt_fcn): + # Utility function to check visible limits + def _get_visible_limits(ax): + xticks = np.array(ax.get_xticks()) + limits = ax.get_xlim() + return np.array([min(xticks[xticks >= limits[0]]), + max(xticks[xticks <= limits[1]])]) + + # Generate a test response with a fixed set of limits + response = ct.singular_values_response( + ct.tf([1], [1, 2, 1]), np.logspace(-1, 1)) + + # Generate a plot without overridding the limits + lines = plt_fcn(response) + ax = ct.get_plot_axes(lines) + np.testing.assert_allclose( + _get_visible_limits(ax.reshape(-1)[0]), np.array([0.1, 10])) + + # Now reset the limits + lines = plt_fcn(response, omega_limits=(1, 100)) + ax = ct.get_plot_axes(lines) + np.testing.assert_allclose( + _get_visible_limits(ax.reshape(-1)[0]), np.array([1, 100])) + + +@pytest.mark.parametrize("plt_fcn", [ct.bode_plot, ct.singular_values_plot]) +def test_freqplot_errors(plt_fcn): + if plt_fcn == ct.bode_plot: + # Turning off both magnitude and phase + with pytest.raises(ValueError, match="no data to plot"): + ct.bode_plot( + manual_response, plot_magnitude=False, plot_phase=False) + + # Specifying frequency parameters with response data + response = ct.singular_values_response(ct.rss(2, 1, 1)) + with pytest.warns(UserWarning, match="`omega_num` ignored "): + plt_fcn(response, omega_num=100) + with pytest.warns(UserWarning, match="`omega` ignored "): + plt_fcn(response, omega=np.logspace(-2, 2)) + + # Bad frequency limits + with pytest.raises(ValueError, match="invalid limits"): + plt_fcn(response, omega_limits=[1e2, 1e-2]) if __name__ == "__main__": diff --git a/control/tests/nyquist_test.py b/control/tests/nyquist_test.py index 18d7e8fb1..1100eb01e 100644 --- a/control/tests/nyquist_test.py +++ b/control/tests/nyquist_test.py @@ -359,11 +359,20 @@ def test_nyquist_exceptions(): def test_linestyle_checks(): - sys = ct.rss(2, 1, 1) + sys = ct.tf([100], [1, 1, 1]) + + # Set the line styles + lines = ct.nyquist_plot( + sys, primary_style=[':', ':'], mirror_style=[':', ':']) + assert all([line.get_linestyle() == ':' for line in lines[0]]) + + # Set the line colors + lines = ct.nyquist_plot(sys, color='g') + assert all([line.get_color() == 'g' for line in lines[0]]) - # Things that should work - ct.nyquist_plot(sys, primary_style=['-', '-'], mirror_style=['-', '-']) - ct.nyquist_plot(sys, mirror_style=None) + # Turn off the mirror image + lines = ct.nyquist_plot(sys, mirror_style=False) + assert lines[0][2:] == [None, None] with pytest.raises(ValueError, match="invalid 'primary_style'"): ct.nyquist_plot(sys, primary_style=False) From 8e84d002e7f5fee85ecfa2ef8b71b9bfe7467a67 Mon Sep 17 00:00:00 2001 From: Richard Murray Date: Wed, 2 Aug 2023 21:39:25 -0700 Subject: [PATCH 17/17] code style and docstring tweaks --- control/freqplot.py | 14 ++++++-------- control/lti.py | 2 +- 2 files changed, 7 insertions(+), 9 deletions(-) diff --git a/control/freqplot.py b/control/freqplot.py index b7f80c64a..164ec28a2 100644 --- a/control/freqplot.py +++ b/control/freqplot.py @@ -1166,14 +1166,11 @@ def nyquist_response( sysdata : LTI or list of LTI List of linear input/output systems (single system is OK). Nyquist curves for each system are plotted on the same graph. - omega : array_like, optional Set of frequencies to be evaluated, in rad/sec. - omega_limits : array_like of two values, optional Limits to the range of frequencies. Ignored if omega is provided, and auto-generated if omitted. - omega_num : int, optional Number of frequency samples to plot. Defaults to config.defaults['freqplot.number_of_samples']. @@ -1182,8 +1179,8 @@ def nyquist_response( ------- responses : list of :class:`~control.NyquistResponseData` For each system, a Nyquist response data object is returned. If - sysdata is a single system, a single elemeent is returned (not a list). - For each response, the following information is available: + `sysdata` is a single system, a single elemeent is returned (not a + list). For each response, the following information is available: response.count : int Number of encirclements of the point -1 by the Nyquist curve. If multiple systems are given, an array of counts is returned. @@ -1742,7 +1739,8 @@ def _parse_linestyle(style_name, allow_false=False): warn_encirclements=kwargs.pop('warn_encirclements', True), warn_nyquist=kwargs.pop('warn_nyquist', True), check_kwargs=False, **kwargs) - else: nyquist_responses = data + else: + nyquist_responses = data # Legacy return value processing if plot is not None or return_contour is not None: @@ -2130,8 +2128,8 @@ def singular_values_response( Parameters ---------- - sys : (list of) LTI systems - List of linear systems (single system is OK). + sysdata : LTI or list of LTI + List of linear input/output systems (single system is OK). omega : array_like List of frequencies in rad/sec to be used for frequency response. omega_limits : array_like of two values diff --git a/control/lti.py b/control/lti.py index e74563c49..cccb44a63 100644 --- a/control/lti.py +++ b/control/lti.py @@ -382,7 +382,7 @@ def frequency_response( Parameters ---------- - sysdata: LTI system or list of LTI systems + sysdata : LTI system or list of LTI systems Linear system(s) for which frequency response is computed. omega : float or 1D array_like, optional A list of frequencies in radians/sec at which the system should be