See More

# phaseplot.py - generate 2D phase portraits # # Initial author: Richard M. Murray # Creation date: 24 July 2011, converted from MATLAB version (2002); # based on an original version by Kristi Morgansen """Generate 2D phase portraits. This module contains functions for generating 2D phase plots. The base function for creating phase plane portraits is `~control.phase_plane_plot`, which generates a phase plane portrait for a 2 state I/O system (with no inputs). Utility functions are available to customize the individual elements of a phase plane portrait. The docstring examples assume the following import commands:: >>> import numpy as np >>> import control as ct >>> import control.phaseplot as pp """ import math import warnings import matplotlib as mpl import matplotlib.pyplot as plt import numpy as np from scipy.integrate import odeint from . import config from .ctrlplot import ControlPlot, _add_arrows_to_line2D, _get_color, \ _process_ax_keyword, _update_plot_title from .exception import ControlArgument from .nlsys import NonlinearIOSystem, find_operating_point, \ input_output_response __all__ = ['phase_plane_plot', 'phase_plot', 'box_grid'] # Default values for module parameter variables _phaseplot_defaults = { 'phaseplot.arrows': 2, # number of arrows around curve 'phaseplot.arrow_size': 8, # pixel size for arrows 'phaseplot.arrow_style': None, # set arrow style 'phaseplot.separatrices_radius': 0.1 # initial radius for separatrices } def phase_plane_plot( sys, pointdata=None, timedata=None, gridtype=None, gridspec=None, plot_streamlines=None, plot_vectorfield=None, plot_streamplot=None, plot_equilpoints=True, plot_separatrices=True, ax=None, suppress_warnings=False, title=None, **kwargs ): """Plot phase plane diagram. This function plots phase plane data, including vector fields, stream lines, equilibrium points, and contour curves. If none of plot_streamlines, plot_vectorfield, or plot_streamplot are set, then plot_streamplot is used by default. Parameters ---------- sys : `NonlinearIOSystem` or callable(t, x, ...) I/O system or function used to generate phase plane data. If a function is given, the remaining arguments are drawn from the `params` keyword. pointdata : list or 2D array List of the form [xmin, xmax, ymin, ymax] describing the boundaries of the phase plot or an array of shape (N, 2) giving points of at which to plot the vector field. timedata : int or list of int Time to simulate each streamline. If a list is given, a different time can be used for each initial condition in `pointdata`. gridtype : str, optional The type of grid to use for generating initial conditions: 'meshgrid' (default) generates a mesh of initial conditions within the specified boundaries, 'boxgrid' generates initial conditions along the edges of the boundary, 'circlegrid' generates a circle of initial conditions around each point in point data. gridspec : list, optional If the gridtype is 'meshgrid' and 'boxgrid', `gridspec` gives the size of the grid in the x and y axes on which to generate points. If gridtype is 'circlegrid', then `gridspec` is a 2-tuple specifying the radius and number of points around each point in the `pointdata` array. params : dict, optional Parameters to pass to system. For an I/O system, `params` should be a dict of parameters and values. For a callable, `params` should be dict with key 'args' and value given by a tuple (passed to callable). color : matplotlib color spec, optional Plot all elements in the given color (use ``plot_`` = {'color': c} to set the color in one element of the phase plot (equilpoints, separatrices, streamlines, etc). ax : `matplotlib.axes.Axes`, optional The matplotlib axes to draw the figure on. If not specified and the current figure has a single axes, that axes is used. Otherwise, a new figure is created. Returns ------- cplt : `ControlPlot` object Object containing the data that were plotted. See `ControlPlot` for more detailed information. cplt.lines : array of list of `matplotlib.lines.Line2D` Array of list of `matplotlib.artist.Artist` objects: - lines[0] = list of Line2D objects (streamlines, separatrices). - lines[1] = Quiver object (vector field arrows). - lines[2] = list of Line2D objects (equilibrium points). - lines[3] = StreamplotSet object (lines with arrows). cplt.axes : 2D array of `matplotlib.axes.Axes` Axes for each subplot. cplt.figure : `matplotlib.figure.Figure` Figure containing the plot. Other Parameters ---------------- arrows : int Set the number of arrows to plot along the streamlines. The default value can be set in `config.defaults['phaseplot.arrows']`. arrow_size : float Set the size of arrows to plot along the streamlines. The default value can be set in `config.defaults['phaseplot.arrow_size']`. arrow_style : matplotlib patch Set the style of arrows to plot along the streamlines. The default value can be set in `config.defaults['phaseplot.arrow_style']`. dir : str, optional Direction to draw streamlines: 'forward' to flow forward in time from the reference points, 'reverse' to flow backward in time, or 'both' to flow both forward and backward. The amount of time to simulate in each direction is given by the `timedata` argument. plot_streamlines : bool or dict, optional If True then plot streamlines based on the pointdata and gridtype. If set to a dict, pass on the key-value pairs in the dict as keywords to `streamlines`. plot_vectorfield : bool or dict, optional If True then plot the vector field based on the pointdata and gridtype. If set to a dict, pass on the key-value pairs in the dict as keywords to `phaseplot.vectorfield`. plot_streamplot : bool or dict, optional If True then use `matplotlib.axes.Axes.streamplot` function to plot the streamlines. If set to a dict, pass on the key-value pairs in the dict as keywords to `phaseplot.streamplot`. plot_equilpoints : bool or dict, optional If True (default) then plot equilibrium points based in the phase plot boundary. If set to a dict, pass on the key-value pairs in the dict as keywords to `phaseplot.equilpoints`. plot_separatrices : bool or dict, optional If True (default) then plot separatrices starting from each equilibrium point. If set to a dict, pass on the key-value pairs in the dict as keywords to `phaseplot.separatrices`. rcParams : dict Override the default parameters used for generating plots. Default is set by `config.defaults['ctrlplot.rcParams']`. suppress_warnings : bool, optional If set to True, suppress warning messages in generating trajectories. title : str, optional Set the title of the plot. Defaults to plot type and system name(s). Notes ----- The default method for producing streamlines is determined based on which keywords are specified, with `plot_streamplot` serving as the generic default. If any of the `arrows`, `arrow_size`, `arrow_style`, or `dir` keywords are used and neither `plot_streamlines` nor `plot_streamplot` is set, then `plot_streamlines` will be set to True. If neither `plot_streamlines` nor `plot_vectorfield` set set to True, then `plot_streamplot` will be set to True. """ # Check for legacy usage of plot_streamlines streamline_keywords = [ 'arrows', 'arrow_size', 'arrow_style', 'dir'] if plot_streamlines is None: if any([kw in kwargs for kw in streamline_keywords]): warnings.warn( "detected streamline keywords; use plot_streamlines to set", FutureWarning) plot_streamlines = True if gridtype not in [None, 'meshgrid']: warnings.warn( "streamplots only support gridtype='meshgrid'; " "falling back to streamlines") plot_streamlines = True if plot_streamlines is None and plot_vectorfield is None \ and plot_streamplot is None: plot_streamplot = True if plot_streamplot and not plot_streamlines and not plot_vectorfield: gridspec = gridspec or [25, 25] # Process arguments params = kwargs.get('params', None) sys = _create_system(sys, params) pointdata = [-1, 1, -1, 1] if pointdata is None else pointdata rcParams = config._get_param('ctrlplot', 'rcParams', kwargs, pop=True) # Create axis if needed user_ax = ax fig, ax = _process_ax_keyword(user_ax, squeeze=True, rcParams=rcParams) # Create copy of kwargs for later checking to find unused arguments initial_kwargs = dict(kwargs) # Utility function to create keyword arguments def _create_kwargs(global_kwargs, local_kwargs, **other_kwargs): new_kwargs = dict(global_kwargs) new_kwargs.update(other_kwargs) if isinstance(local_kwargs, dict): new_kwargs.update(local_kwargs) return new_kwargs # Create list for storing outputs out = np.array([[], None, None, None], dtype=object) # the maximum zorder of stramlines, vectorfield or streamplot flow_zorder = None # Plot out the main elements if plot_streamlines: kwargs_local = _create_kwargs( kwargs, plot_streamlines, gridspec=gridspec, gridtype=gridtype, ax=ax) out[0] += streamlines( sys, pointdata, timedata, _check_kwargs=False, suppress_warnings=suppress_warnings, **kwargs_local) new_zorder = max(elem.get_zorder() for elem in out[0]) flow_zorder = max(flow_zorder, new_zorder) if flow_zorder \ else new_zorder # Get rid of keyword arguments handled by streamlines for kw in ['arrows', 'arrow_size', 'arrow_style', 'color', 'dir', 'params']: initial_kwargs.pop(kw, None) # Reset the gridspec for the remaining commands, if needed if gridtype not in [None, 'boxgrid', 'meshgrid']: gridspec = None if plot_vectorfield: kwargs_local = _create_kwargs( kwargs, plot_vectorfield, gridspec=gridspec, ax=ax) out[1] = vectorfield( sys, pointdata, _check_kwargs=False, **kwargs_local) new_zorder = out[1].get_zorder() flow_zorder = max(flow_zorder, new_zorder) if flow_zorder \ else new_zorder # Get rid of keyword arguments handled by vectorfield for kw in ['color', 'params']: initial_kwargs.pop(kw, None) if plot_streamplot: if gridtype not in [None, 'meshgrid']: raise ValueError( "gridtype must be 'meshgrid' when using streamplot") kwargs_local = _create_kwargs( kwargs, plot_streamplot, gridspec=gridspec, ax=ax) out[3] = streamplot( sys, pointdata, _check_kwargs=False, **kwargs_local) new_zorder = max(out[3].lines.get_zorder(), out[3].arrows.get_zorder()) flow_zorder = max(flow_zorder, new_zorder) if flow_zorder \ else new_zorder # Get rid of keyword arguments handled by streamplot for kw in ['color', 'params']: initial_kwargs.pop(kw, None) sep_zorder = flow_zorder + 1 if flow_zorder else None if plot_separatrices: kwargs_local = _create_kwargs( kwargs, plot_separatrices, gridspec=gridspec, ax=ax) kwargs_local['zorder'] = kwargs_local.get('zorder', sep_zorder) out[0] += separatrices( sys, pointdata, _check_kwargs=False, **kwargs_local) sep_zorder = max(elem.get_zorder() for elem in out[0]) if out[0] \ else None # Get rid of keyword arguments handled by separatrices for kw in ['arrows', 'arrow_size', 'arrow_style', 'params']: initial_kwargs.pop(kw, None) equil_zorder = sep_zorder + 1 if sep_zorder else None if plot_equilpoints: kwargs_local = _create_kwargs( kwargs, plot_equilpoints, gridspec=gridspec, ax=ax) kwargs_local['zorder'] = kwargs_local.get('zorder', equil_zorder) out[2] = equilpoints( sys, pointdata, _check_kwargs=False, **kwargs_local) # Get rid of keyword arguments handled by equilpoints for kw in ['params']: initial_kwargs.pop(kw, None) # Make sure all keyword arguments were used if initial_kwargs: raise TypeError("unrecognized keywords: ", str(initial_kwargs)) if user_ax is None: if title is None: title = f"Phase portrait for {sys.name}" _update_plot_title(title, use_existing=False, rcParams=rcParams) ax.set_xlabel(sys.state_labels[0]) ax.set_ylabel(sys.state_labels[1]) plt.tight_layout() return ControlPlot(out, ax, fig) def vectorfield( sys, pointdata, gridspec=None, zorder=None, ax=None, suppress_warnings=False, _check_kwargs=True, **kwargs): """Plot a vector field in the phase plane. This function plots a vector field for a two-dimensional state space system. Parameters ---------- sys : `NonlinearIOSystem` or callable(t, x, ...) I/O system or function used to generate phase plane data. If a function is given, the remaining arguments are drawn from the `params` keyword. pointdata : list or 2D array List of the form [xmin, xmax, ymin, ymax] describing the boundaries of the phase plot or an array of shape (N, 2) giving points of at which to plot the vector field. gridtype : str, optional The type of grid to use for generating initial conditions: 'meshgrid' (default) generates a mesh of initial conditions within the specified boundaries, 'boxgrid' generates initial conditions along the edges of the boundary, 'circlegrid' generates a circle of initial conditions around each point in point data. gridspec : list, optional If the gridtype is 'meshgrid' and 'boxgrid', `gridspec` gives the size of the grid in the x and y axes on which to generate points. If gridtype is 'circlegrid', then `gridspec` is a 2-tuple specifying the radius and number of points around each point in the `pointdata` array. params : dict or list, optional Parameters to pass to system. For an I/O system, `params` should be a dict of parameters and values. For a callable, `params` should be dict with key 'args' and value given by a tuple (passed to callable). color : matplotlib color spec, optional Plot the vector field in the given color. ax : `matplotlib.axes.Axes`, optional Use the given axes for the plot, otherwise use the current axes. Returns ------- out : Quiver Other Parameters ---------------- rcParams : dict Override the default parameters used for generating plots. Default is set by `config.defaults['ctrlplot.rcParams']`. suppress_warnings : bool, optional If set to True, suppress warning messages in generating trajectories. zorder : float, optional Set the zorder for the vectorfield. In not specified, it will be automatically chosen by `matplotlib.axes.Axes.quiver`. """ # Process keywords rcParams = config._get_param('ctrlplot', 'rcParams', kwargs, pop=True) # Get system parameters params = kwargs.pop('params', None) # Create system from callable, if needed sys = _create_system(sys, params) # Determine the points on which to generate the vector field points, _ = _make_points(pointdata, gridspec, 'meshgrid') # Create axis if needed if ax is None: ax = plt.gca() # Set the plotting limits xlim, ylim, maxlim = _set_axis_limits(ax, pointdata) # Figure out the color to use color = _get_color(kwargs, ax=ax) # Make sure all keyword arguments were processed if _check_kwargs and kwargs: raise TypeError("unrecognized keywords: ", str(kwargs)) # Generate phase plane (quiver) data vfdata = np.zeros((points.shape[0], 4)) sys._update_params(params) for i, x in enumerate(points): vfdata[i, :2] = x vfdata[i, 2:] = sys._rhs(0, x, np.zeros(sys.ninputs)) with plt.rc_context(rcParams): out = ax.quiver( vfdata[:, 0], vfdata[:, 1], vfdata[:, 2], vfdata[:, 3], angles='xy', color=color, zorder=zorder) return out def streamplot( sys, pointdata, gridspec=None, zorder=None, ax=None, vary_color=False, vary_linewidth=False, cmap=None, norm=None, suppress_warnings=False, _check_kwargs=True, **kwargs): """Plot streamlines in the phase plane. This function plots the streamlines for a two-dimensional state space system using the `matplotlib.axes.Axes.streamplot` function. Parameters ---------- sys : `NonlinearIOSystem` or callable(t, x, ...) I/O system or function used to generate phase plane data. If a function is given, the remaining arguments are drawn from the `params` keyword. pointdata : list or 2D array List of the form [xmin, xmax, ymin, ymax] describing the boundaries of the phase plot. gridspec : list, optional Specifies the size of the grid in the x and y axes on which to generate points. params : dict or list, optional Parameters to pass to system. For an I/O system, `params` should be a dict of parameters and values. For a callable, `params` should be dict with key 'args' and value given by a tuple (passed to callable). color : matplotlib color spec, optional Plot the vector field in the given color. ax : `matplotlib.axes.Axes`, optional Use the given axes for the plot, otherwise use the current axes. Returns ------- out : StreamplotSet Containter object with lines and arrows contained in the streamplot. See `matplotlib.axes.Axes.streamplot` for details. Other Parameters ---------------- cmap : str or Colormap, optional Colormap to use for varying the color of the streamlines. norm : `matplotlib.colors.Normalize`, optional Normalization map to use for scaling the colormap and linewidths. rcParams : dict Override the default parameters used for generating plots. Default is set by `config.default['ctrlplot.rcParams']`. suppress_warnings : bool, optional If set to True, suppress warning messages in generating trajectories. vary_color : bool, optional If set to True, vary the color of the streamlines based on the magnitude of the vector field. vary_linewidth : bool, optional. If set to True, vary the linewidth of the streamlines based on the magnitude of the vector field. zorder : float, optional Set the zorder for the streamlines. In not specified, it will be automatically chosen by `matplotlib.axes.Axes.streamplot`. """ # Process keywords rcParams = config._get_param('ctrlplot', 'rcParams', kwargs, pop=True) # Get system parameters params = kwargs.pop('params', None) # Create system from callable, if needed sys = _create_system(sys, params) # Determine the points on which to generate the streamplot field points, gridspec = _make_points(pointdata, gridspec, 'meshgrid') grid_arr_shape = gridspec[::-1] xs = points[:, 0].reshape(grid_arr_shape) ys = points[:, 1].reshape(grid_arr_shape) # Create axis if needed if ax is None: ax = plt.gca() # Set the plotting limits xlim, ylim, maxlim = _set_axis_limits(ax, pointdata) # Figure out the color to use color = _get_color(kwargs, ax=ax) # Make sure all keyword arguments were processed if _check_kwargs and kwargs: raise TypeError("unrecognized keywords: ", str(kwargs)) # Generate phase plane (quiver) data sys._update_params(params) us_flat, vs_flat = np.transpose( [sys._rhs(0, x, np.zeros(sys.ninputs)) for x in points]) us, vs = us_flat.reshape(grid_arr_shape), vs_flat.reshape(grid_arr_shape) magnitudes = np.linalg.norm([us, vs], axis=0) norm = norm or mpl.colors.Normalize() normalized = norm(magnitudes) cmap = plt.get_cmap(cmap) with plt.rc_context(rcParams): default_lw = plt.rcParams['lines.linewidth'] min_lw, max_lw = 0.25*default_lw, 2*default_lw linewidths = normalized * (max_lw - min_lw) + min_lw \ if vary_linewidth else None color = magnitudes if vary_color else color out = ax.streamplot( xs, ys, us, vs, color=color, linewidth=linewidths, cmap=cmap, norm=norm, zorder=zorder) return out def streamlines( sys, pointdata, timedata=1, gridspec=None, gridtype=None, dir=None, zorder=None, ax=None, _check_kwargs=True, suppress_warnings=False, **kwargs): """Plot stream lines in the phase plane. This function plots stream lines for a two-dimensional state space system. Parameters ---------- sys : `NonlinearIOSystem` or callable(t, x, ...) I/O system or function used to generate phase plane data. If a function is given, the remaining arguments are drawn from the `params` keyword. pointdata : list or 2D array List of the form [xmin, xmax, ymin, ymax] describing the boundaries of the phase plot or an array of shape (N, 2) giving points of at which to plot the vector field. timedata : int or list of int Time to simulate each streamline. If a list is given, a different time can be used for each initial condition in `pointdata`. gridtype : str, optional The type of grid to use for generating initial conditions: 'meshgrid' (default) generates a mesh of initial conditions within the specified boundaries, 'boxgrid' generates initial conditions along the edges of the boundary, 'circlegrid' generates a circle of initial conditions around each point in point data. gridspec : list, optional If the gridtype is 'meshgrid' and 'boxgrid', `gridspec` gives the size of the grid in the x and y axes on which to generate points. If gridtype is 'circlegrid', then `gridspec` is a 2-tuple specifying the radius and number of points around each point in the `pointdata` array. dir : str, optional Direction to draw streamlines: 'forward' to flow forward in time from the reference points, 'reverse' to flow backward in time, or 'both' to flow both forward and backward. The amount of time to simulate in each direction is given by the `timedata` argument. params : dict or list, optional Parameters to pass to system. For an I/O system, `params` should be a dict of parameters and values. For a callable, `params` should be dict with key 'args' and value given by a tuple (passed to callable). color : str Plot the streamlines in the given color. ax : `matplotlib.axes.Axes`, optional Use the given axes for the plot, otherwise use the current axes. Returns ------- out : list of Line2D objects Other Parameters ---------------- arrows : int Set the number of arrows to plot along the streamlines. The default value can be set in `config.defaults['phaseplot.arrows']`. arrow_size : float Set the size of arrows to plot along the streamlines. The default value can be set in `config.defaults['phaseplot.arrow_size']`. arrow_style : matplotlib patch Set the style of arrows to plot along the streamlines. The default value can be set in `config.defaults['phaseplot.arrow_style']`. rcParams : dict Override the default parameters used for generating plots. Default is set by `config.defaults['ctrlplot.rcParams']`. suppress_warnings : bool, optional If set to True, suppress warning messages in generating trajectories. zorder : float, optional Set the zorder for the streamlines. In not specified, it will be automatically chosen by `matplotlib.axes.Axes.plot`. """ # Process keywords rcParams = config._get_param('ctrlplot', 'rcParams', kwargs, pop=True) # Get system parameters params = kwargs.pop('params', None) # Create system from callable, if needed sys = _create_system(sys, params) # Parse the arrows keyword arrow_pos, arrow_style = _parse_arrow_keywords(kwargs) # Determine the points on which to generate the streamlines points, gridspec = _make_points(pointdata, gridspec, gridtype=gridtype) if dir is None: dir = 'both' if gridtype == 'meshgrid' else 'forward' # Create axis if needed if ax is None: ax = plt.gca() # Set the axis limits xlim, ylim, maxlim = _set_axis_limits(ax, pointdata) # Figure out the color to use color = _get_color(kwargs, ax=ax) # Make sure all keyword arguments were processed if _check_kwargs and kwargs: raise TypeError("unrecognized keywords: ", str(kwargs)) # Create reverse time system, if needed if dir != 'forward': revsys = NonlinearIOSystem( lambda t, x, u, params: -np.asarray(sys.updfcn(t, x, u, params)), sys.outfcn, states=sys.nstates, inputs=sys.ninputs, outputs=sys.noutputs, params=sys.params) else: revsys = None # Generate phase plane (streamline) data out = [] for i, X0 in enumerate(points): # Create the trajectory for this point timepts = _make_timepts(timedata, i) traj = _create_trajectory( sys, revsys, timepts, X0, params, dir, gridtype=gridtype, gridspec=gridspec, xlim=xlim, ylim=ylim, suppress_warnings=suppress_warnings) # Plot the trajectory (if there is one) if traj.shape[1] > 1: with plt.rc_context(rcParams): out += ax.plot(traj[0], traj[1], color=color, zorder=zorder) # Add arrows to the lines at specified intervals _add_arrows_to_line2D( ax, out[-1], arrow_pos, arrowstyle=arrow_style, dir=1) return out def equilpoints( sys, pointdata, gridspec=None, color='k', zorder=None, ax=None, _check_kwargs=True, **kwargs): """Plot equilibrium points in the phase plane. This function plots the equilibrium points for a planar dynamical system. Parameters ---------- sys : `NonlinearIOSystem` or callable(t, x, ...) I/O system or function used to generate phase plane data. If a function is given, the remaining arguments are drawn from the `params` keyword. pointdata : list or 2D array List of the form [xmin, xmax, ymin, ymax] describing the boundaries of the phase plot or an array of shape (N, 2) giving points of at which to plot the vector field. gridtype : str, optional The type of grid to use for generating initial conditions: 'meshgrid' (default) generates a mesh of initial conditions within the specified boundaries, 'boxgrid' generates initial conditions along the edges of the boundary, 'circlegrid' generates a circle of initial conditions around each point in point data. gridspec : list, optional If the gridtype is 'meshgrid' and 'boxgrid', `gridspec` gives the size of the grid in the x and y axes on which to generate points. If gridtype is 'circlegrid', then `gridspec` is a 2-tuple specifying the radius and number of points around each point in the `pointdata` array. params : dict or list, optional Parameters to pass to system. For an I/O system, `params` should be a dict of parameters and values. For a callable, `params` should be dict with key 'args' and value given by a tuple (passed to callable). color : str Plot the equilibrium points in the given color. ax : `matplotlib.axes.Axes`, optional Use the given axes for the plot, otherwise use the current axes. Returns ------- out : list of Line2D objects Other Parameters ---------------- rcParams : dict Override the default parameters used for generating plots. Default is set by `config.defaults['ctrlplot.rcParams']`. zorder : float, optional Set the zorder for the equilibrium points. In not specified, it will be automatically chosen by `matplotlib.axes.Axes.plot`. """ # Process keywords rcParams = config._get_param('ctrlplot', 'rcParams', kwargs, pop=True) # Get system parameters params = kwargs.pop('params', None) # Create system from callable, if needed sys = _create_system(sys, params) # Create axis if needed if ax is None: ax = plt.gca() # Set the axis limits xlim, ylim, maxlim = _set_axis_limits(ax, pointdata) # Determine the points on which to generate the vector field gridspec = [5, 5] if gridspec is None else gridspec points, _ = _make_points(pointdata, gridspec, 'meshgrid') # Make sure all keyword arguments were processed if _check_kwargs and kwargs: raise TypeError("unrecognized keywords: ", str(kwargs)) # Search for equilibrium points equilpts = _find_equilpts(sys, points, params=params) # Plot the equilibrium points out = [] for xeq in equilpts: with plt.rc_context(rcParams): out += ax.plot( xeq[0], xeq[1], marker='o', color=color, zorder=zorder) return out def separatrices( sys, pointdata, timedata=None, gridspec=None, zorder=None, ax=None, _check_kwargs=True, suppress_warnings=False, **kwargs): """Plot separatrices in the phase plane. This function plots separatrices for a two-dimensional state space system. Parameters ---------- sys : `NonlinearIOSystem` or callable(t, x, ...) I/O system or function used to generate phase plane data. If a function is given, the remaining arguments are drawn from the `params` keyword. pointdata : list or 2D array List of the form [xmin, xmax, ymin, ymax] describing the boundaries of the phase plot or an array of shape (N, 2) giving points of at which to plot the vector field. timedata : int or list of int Time to simulate each streamline. If a list is given, a different time can be used for each initial condition in `pointdata`. gridtype : str, optional The type of grid to use for generating initial conditions: 'meshgrid' (default) generates a mesh of initial conditions within the specified boundaries, 'boxgrid' generates initial conditions along the edges of the boundary, 'circlegrid' generates a circle of initial conditions around each point in point data. gridspec : list, optional If the gridtype is 'meshgrid' and 'boxgrid', `gridspec` gives the size of the grid in the x and y axes on which to generate points. If gridtype is 'circlegrid', then `gridspec` is a 2-tuple specifying the radius and number of points around each point in the `pointdata` array. params : dict or list, optional Parameters to pass to system. For an I/O system, `params` should be a dict of parameters and values. For a callable, `params` should be dict with key 'args' and value given by a tuple (passed to callable). color : matplotlib color spec, optional Plot the separatrices in the given color. If a single color specification is given, this is used for both stable and unstable separatrices. If a tuple is given, the first element is used as the color specification for stable separatrices and the second element for unstable separatrices. ax : `matplotlib.axes.Axes`, optional Use the given axes for the plot, otherwise use the current axes. Returns ------- out : list of Line2D objects Other Parameters ---------------- rcParams : dict Override the default parameters used for generating plots. Default is set by `config.defaults['ctrlplot.rcParams']`. suppress_warnings : bool, optional If set to True, suppress warning messages in generating trajectories. zorder : float, optional Set the zorder for the separatrices. In not specified, it will be automatically chosen by `matplotlib.axes.Axes.plot`. Notes ----- The value of `config.defaults['separatrices_radius']` is used to set the offset from the equilibrium point to the starting point of the separatix traces, in the direction of the eigenvectors evaluated at that equilibrium point. """ # Process keywords rcParams = config._get_param('ctrlplot', 'rcParams', kwargs, pop=True) # Get system parameters params = kwargs.pop('params', None) # Create system from callable, if needed sys = _create_system(sys, params) # Parse the arrows keyword arrow_pos, arrow_style = _parse_arrow_keywords(kwargs) # Determine the initial states to use in searching for equilibrium points gridspec = [5, 5] if gridspec is None else gridspec points, _ = _make_points(pointdata, gridspec, 'meshgrid') # Find the equilibrium points equilpts = _find_equilpts(sys, points, params=params) radius = config._get_param('phaseplot', 'separatrices_radius') # Create axis if needed if ax is None: ax = plt.gca() # Set the axis limits xlim, ylim, maxlim = _set_axis_limits(ax, pointdata) # Figure out the color to use for stable, unstable subspaces color = _get_color(kwargs) match color: case None: stable_color = 'r' unstable_color = 'b' case (stable_color, unstable_color) | [stable_color, unstable_color]: pass case single_color: stable_color = unstable_color = single_color # Make sure all keyword arguments were processed if _check_kwargs and kwargs: raise TypeError("unrecognized keywords: ", str(kwargs)) # Create a "reverse time" system to use for simulation revsys = NonlinearIOSystem( lambda t, x, u, params: -np.array(sys.updfcn(t, x, u, params)), sys.outfcn, states=sys.nstates, inputs=sys.ninputs, outputs=sys.noutputs, params=sys.params) # Plot separatrices by flowing backwards in time along eigenspaces out = [] for i, xeq in enumerate(equilpts): # Figure out the linearization and eigenvectors evals, evecs = np.linalg.eig(sys.linearize(xeq, 0, params=params).A) # See if we have real eigenvalues (=> evecs are meaningful) if evals[0].imag > 0: continue # Create default list of time points if timedata is not None: timepts = _make_timepts(timedata, i) # Generate the traces for j, dir in enumerate(evecs.T): # Figure out time vector if not yet computed if timedata is None: timescale = math.log(maxlim / radius) / abs(evals[j].real) timepts = np.linspace(0, timescale) # Run the trajectory starting in eigenvector directions for eps in [-radius, radius]: x0 = xeq + dir * eps if evals[j].real < 0: traj = _create_trajectory( sys, revsys, timepts, x0, params, 'reverse', gridtype='boxgrid', xlim=xlim, ylim=ylim, suppress_warnings=suppress_warnings) color = stable_color linestyle = '--' elif evals[j].real > 0: traj = _create_trajectory( sys, revsys, timepts, x0, params, 'forward', gridtype='boxgrid', xlim=xlim, ylim=ylim, suppress_warnings=suppress_warnings) color = unstable_color linestyle = '-' # Plot the trajectory (if there is one) if traj.shape[1] > 1: with plt.rc_context(rcParams): out += ax.plot( traj[0], traj[1], color=color, linestyle=linestyle, zorder=zorder) # Add arrows to the lines at specified intervals with plt.rc_context(rcParams): _add_arrows_to_line2D( ax, out[-1], arrow_pos, arrowstyle=arrow_style, dir=1) return out # # User accessible utility functions # # Utility function to generate boxgrid (in the form needed here) def boxgrid(xvals, yvals): """Generate list of points along the edge of box. points = boxgrid(xvals, yvals) generates a list of points that corresponds to a grid given by the cross product of the x and y values. Parameters ---------- xvals, yvals : 1D array_like Array of points defining the points on the lower and left edges of the box. Returns ------- grid : 2D array Array with shape (p, 2) defining the points along the edges of the box, where p is the number of points around the edge. """ return np.array( [(x, yvals[0]) for x in xvals[:-1]] + # lower edge [(xvals[-1], y) for y in yvals[:-1]] + # right edge [(x, yvals[-1]) for x in xvals[:0:-1]] + # upper edge [(xvals[0], y) for y in yvals[:0:-1]] # left edge ) # Utility function to generate meshgrid (in the form needed here) # TODO: add examples of using grid functions directly def meshgrid(xvals, yvals): """Generate list of points forming a mesh. points = meshgrid(xvals, yvals) generates a list of points that corresponds to a grid given by the cross product of the x and y values. Parameters ---------- xvals, yvals : 1D array_like Array of points defining the points on the lower and left edges of the box. Returns ------- grid : 2D array Array of points with shape (n * m, 2) defining the mesh. """ xvals, yvals = np.meshgrid(xvals, yvals) grid = np.zeros((xvals.shape[0] * xvals.shape[1], 2)) grid[:, 0] = xvals.reshape(-1) grid[:, 1] = yvals.reshape(-1) return grid # Utility function to generate circular grid def circlegrid(centers, radius, num): """Generate list of points around a circle. points = circlegrid(centers, radius, num) generates a list of points that form a circle around a list of centers. Parameters ---------- centers : 2D array_like Array of points with shape (p, 2) defining centers of the circles. radius : float Radius of the points to be generated around each center. num : int Number of points to generate around the circle. Returns ------- grid : 2D array Array of points with shape (p * num, 2) defining the circles. """ centers = np.atleast_2d(np.array(centers)) grid = np.zeros((centers.shape[0] * num, 2)) for i, center in enumerate(centers): grid[i * num: (i + 1) * num, :] = center + np.array([ [radius * math.cos(theta), radius * math.sin(theta)] for theta in np.linspace(0, 2 * math.pi, num, endpoint=False)]) return grid # # Internal utility functions # # Create a system from a callable def _create_system(sys, params): if isinstance(sys, NonlinearIOSystem): if sys.nstates != 2: raise ValueError("system must be planar") return sys # Make sure that if params is present, it has 'args' key if params and not params.get('args', None): raise ValueError("params must be dict with key 'args'") _update = lambda t, x, u, params: sys(t, x, *params.get('args', ())) _output = lambda t, x, u, params: np.array([]) return NonlinearIOSystem( _update, _output, states=2, inputs=0, outputs=0, name="_callable") # Set axis limits for the plot def _set_axis_limits(ax, pointdata): # Get the current axis limits if ax.lines: xlim, ylim = ax.get_xlim(), ax.get_ylim() else: # Nothing on the plot => always use new limits xlim, ylim = [np.inf, -np.inf], [np.inf, -np.inf] # Short utility function for updating axis limits def _update_limits(cur, new): return [min(cur[0], np.min(new)), max(cur[1], np.max(new))] # If we were passed a box, use that to update the limits if isinstance(pointdata, list) and len(pointdata) == 4: xlim = _update_limits(xlim, [pointdata[0], pointdata[1]]) ylim = _update_limits(ylim, [pointdata[2], pointdata[3]]) elif isinstance(pointdata, np.ndarray): pointdata = np.atleast_2d(pointdata) xlim = _update_limits( xlim, [np.min(pointdata[:, 0]), np.max(pointdata[:, 0])]) ylim = _update_limits( ylim, [np.min(pointdata[:, 1]), np.max(pointdata[:, 1])]) # Keep track of the largest dimension on the plot maxlim = max(xlim[1] - xlim[0], ylim[1] - ylim[0]) # Set the new limits ax.autoscale(enable=True, axis='x', tight=True) ax.autoscale(enable=True, axis='y', tight=True) ax.set_xlim(xlim) ax.set_ylim(ylim) return xlim, ylim, maxlim # Find equilibrium points def _find_equilpts(sys, points, params=None): equilpts = [] for i, x0 in enumerate(points): # Look for an equilibrium point near this point xeq, ueq = find_operating_point(sys, x0, 0, params=params) if xeq is None: continue # didn't find anything # See if we have already found this point seen = False for x in equilpts: if np.allclose(np.array(x), xeq): seen = True if seen: continue # Save a new point equilpts += [xeq.tolist()] return equilpts def _make_points(pointdata, gridspec, gridtype): # Check to see what type of data we got if isinstance(pointdata, np.ndarray) and gridtype is None: pointdata = np.atleast_2d(pointdata) if pointdata.shape[1] == 2: # Given a list of points => no action required return pointdata, None # Utility function to parse (and check) input arguments def _parse_args(defsize): if gridspec is None: return defsize elif not isinstance(gridspec, (list, tuple)) or \ len(gridspec) != len(defsize): raise ValueError("invalid grid specification") return gridspec # Generate points based on grid type match gridtype: case 'boxgrid' | None: gridspec = _parse_args([6, 4]) points = boxgrid( np.linspace(pointdata[0], pointdata[1], gridspec[0]), np.linspace(pointdata[2], pointdata[3], gridspec[1])) case 'meshgrid': gridspec = _parse_args([9, 6]) points = meshgrid( np.linspace(pointdata[0], pointdata[1], gridspec[0]), np.linspace(pointdata[2], pointdata[3], gridspec[1])) case 'circlegrid': gridspec = _parse_args((0.5, 10)) if isinstance(pointdata, np.ndarray): # Create circles around each point points = circlegrid(pointdata, gridspec[0], gridspec[1]) else: # Create circle around center of the plot points = circlegrid( np.array( [(pointdata[0] + pointdata[1]) / 2, (pointdata[0] + pointdata[1]) / 2]), gridspec[0], gridspec[1]) case _: raise ValueError(f"unknown grid type '{gridtype}'") return points, gridspec def _parse_arrow_keywords(kwargs): # Get values for params (and pop from list to allow keyword use in plot) # TODO: turn this into a utility function (shared with nyquist_plot?) arrows = config._get_param( 'phaseplot', 'arrows', kwargs, None, pop=True) arrow_size = config._get_param( 'phaseplot', 'arrow_size', kwargs, None, pop=True) arrow_style = config._get_param('phaseplot', 'arrow_style', kwargs, None) # 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=int(2 * arrow_size / 3), head_length=arrow_size) return arrow_pos, arrow_style # TODO: move to ctrlplot? def _create_trajectory( sys, revsys, timepts, X0, params, dir, suppress_warnings=False, gridtype=None, gridspec=None, xlim=None, ylim=None): # Compute the forward trajectory if dir == 'forward' or dir == 'both': fwdresp = input_output_response( sys, timepts, initial_state=X0, params=params, ignore_errors=True) if not fwdresp.success and not suppress_warnings: warnings.warn(f"initial_state={X0}, {fwdresp.message}") # Compute the reverse trajectory if dir == 'reverse' or dir == 'both': revresp = input_output_response( revsys, timepts, initial_state=X0, params=params, ignore_errors=True) if not revresp.success and not suppress_warnings: warnings.warn(f"initial_state={X0}, {revresp.message}") # Create the trace to plot if dir == 'forward': traj = fwdresp.states elif dir == 'reverse': traj = revresp.states[:, ::-1] elif dir == 'both': traj = np.hstack([revresp.states[:, :1:-1], fwdresp.states]) # Remove points outside the window (keep first point beyond boundary) inrange = np.asarray( (traj[0] >= xlim[0]) & (traj[0] <= xlim[1]) & (traj[1] >= ylim[0]) & (traj[1] <= ylim[1])) inrange[:-1] = inrange[:-1] | inrange[1:] # keep if next point in range inrange[1:] = inrange[1:] | inrange[:-1] # keep if prev point in range return traj[:, inrange] def _make_timepts(timepts, i): if timepts is None: return np.linspace(0, 1) elif isinstance(timepts, (int, float)): return np.linspace(0, timepts) elif timepts.ndim == 2: return timepts[i] return timepts # # Legacy phase plot function # # Author: Richard Murray # Date: 24 July 2011, converted from MATLAB version (2002); based on # a version by Kristi Morgansen # def phase_plot(odefun, X=None, Y=None, scale=1, X0=None, T=None, lingrid=None, lintime=None, logtime=None, timepts=None, parms=None, params=(), tfirst=False, verbose=True): """(legacy) Phase plot for 2D dynamical systems. .. deprecated:: 0.10.1 This function is deprecated; use `phase_plane_plot` instead. Produces a vector field or stream line plot for a planar system. This function has been replaced by the `phase_plane_map` and `phase_plane_plot` functions. Call signatures: phase_plot(func, X, Y, ...) - display vector field on meshgrid phase_plot(func, X, Y, scale, ...) - scale arrows phase_plot(func. X0=(...), T=Tmax, ...) - display stream lines phase_plot(func, X, Y, X0=[...], T=Tmax, ...) - plot both phase_plot(func, X0=[...], T=Tmax, lingrid=N, ...) - plot both phase_plot(func, X0=[...], lintime=N, ...) - stream lines with arrows Parameters ---------- func : callable(x, t, ...) Computes the time derivative of y (compatible with odeint). The function should be the same for as used for `scipy.integrate`. Namely, it should be a function of the form dx/dt = F(t, x) that accepts a state x of dimension 2 and returns a derivative dx/dt of dimension 2. X, Y: 3-element sequences, optional, as [start, stop, npts] Two 3-element sequences specifying x and y coordinates of a grid. These arguments are passed to linspace and meshgrid to generate the points at which the vector field is plotted. If absent (or None), the vector field is not plotted. scale: float, optional Scale size of arrows; default = 1 X0: ndarray of initial conditions, optional List of initial conditions from which streamlines are plotted. Each initial condition should be a pair of numbers. T: array_like or number, optional Length of time to run simulations that generate streamlines. If a single number, the same simulation time is used for all initial conditions. Otherwise, should be a list of length len(X0) that gives the simulation time for each initial condition. Default value = 50. lingrid : integer or 2-tuple of integers, optional Argument is either N or (N, M). If X0 is given and X, Y are missing, a grid of arrows is produced using the limits of the initial conditions, with N grid points in each dimension or N grid points in x and M grid points in y. lintime : integer or tuple (integer, float), optional If a single integer N is given, draw N arrows using equally space time points. If a tuple (N, lambda) is given, draw N arrows using exponential time constant lambda timepts : array_like, optional Draw arrows at the given list times [t1, t2, ...] tfirst : bool, optional If True, call `func` with signature ``func(t, x, ...)``. params: tuple, optional List of parameters to pass to vector field: ``func(x, t, *params)``. See Also -------- box_grid """ # Generate a deprecation warning warnings.warn( "phase_plot() is deprecated; use phase_plane_plot() instead", FutureWarning) # # Figure out ranges for phase plot (argument processing) # #! TODO: need to add error checking to arguments #! TODO: think through proper action if multiple options are given # autoFlag = False logtimeFlag = False timeptsFlag = False Narrows = 0 # Get parameters to pass to function if parms: warnings.warn( "keyword 'parms' is deprecated; use 'params'", FutureWarning) if params: raise ControlArgument("duplicate keywords 'parms' and 'params'") else: params = parms if lingrid is not None: autoFlag = True Narrows = lingrid if (verbose): print('Using auto arrows\n') elif logtime is not None: logtimeFlag = True Narrows = logtime[0] timefactor = logtime[1] if (verbose): print('Using logtime arrows\n') elif timepts is not None: timeptsFlag = True Narrows = len(timepts) # Figure out the set of points for the quiver plot #! TODO: Add sanity checks elif X is not None and Y is not None: x1, x2 = np.meshgrid( np.linspace(X[0], X[1], X[2]), np.linspace(Y[0], Y[1], Y[2])) Narrows = len(x1) else: # If we weren't given any grid points, don't plot arrows Narrows = 0 if not autoFlag and not logtimeFlag and not timeptsFlag and Narrows > 0: # Now calculate the vector field at those points (nr,nc) = x1.shape dx = np.empty((nr, nc, 2)) for i in range(nr): for j in range(nc): if tfirst: dx[i, j, :] = np.squeeze( odefun(0, [x1[i,j], x2[i,j]], *params)) else: dx[i, j, :] = np.squeeze( odefun([x1[i,j], x2[i,j]], 0, *params)) # Plot the quiver plot #! TODO: figure out arguments to make arrows show up correctly if scale is None: plt.quiver(x1, x2, dx[:,:,1], dx[:,:,2], angles='xy') elif (scale != 0): plt.quiver(x1, x2, dx[:,:,0]*np.abs(scale), dx[:,:,1]*np.abs(scale), angles='xy') #! TODO: optimize parameters for arrows #! TODO: figure out arguments to make arrows show up correctly # xy = plt.quiver(...) # set(xy, 'LineWidth', PP_arrow_linewidth, 'Color', 'b') #! TODO: Tweak the shape of the plot # a=gca; set(a,'DataAspectRatio',[1,1,1]) # set(a,'XLim',X(1:2)); set(a,'YLim',Y(1:2)) plt.xlabel('x1'); plt.ylabel('x2') # See if we should also generate the streamlines if X0 is None or len(X0) == 0: return # Convert initial conditions to a numpy array X0 = np.array(X0) (nr, nc) = np.shape(X0) # Generate some empty matrices to keep arrow information x1 = np.empty((nr, Narrows)) x2 = np.empty((nr, Narrows)) dx = np.empty((nr, Narrows, 2)) # See if we were passed a simulation time if T is None: T = 50 # Parse the time we were passed TSPAN = T if isinstance(T, (int, float)): TSPAN = np.linspace(0, T, 100) # Figure out the limits for the plot if scale is None: # Assume that the current axis are set as we want them alim = plt.axis() xmin = alim[0]; xmax = alim[1] ymin = alim[2]; ymax = alim[3] else: # Use the maximum extent of all trajectories xmin = np.min(X0[:,0]); xmax = np.max(X0[:,0]) ymin = np.min(X0[:,1]); ymax = np.max(X0[:,1]) # Generate the streamlines for each initial condition for i in range(nr): state = odeint(odefun, X0[i], TSPAN, args=params, tfirst=tfirst) time = TSPAN plt.plot(state[:,0], state[:,1]) #! TODO: add back in colors for stream lines # PP_stream_color(np.mod(i-1, len(PP_stream_color))+1)) # set(h[i], 'LineWidth', PP_stream_linewidth) # Plot arrows if quiver parameters were 'auto' if autoFlag or logtimeFlag or timeptsFlag: # Compute the locations of the arrows #! TODO: check this logic to make sure it works in python for j in range(Narrows): # Figure out starting index; headless arrows start at 0 k = -1 if scale is None else 0 # Figure out what time index to use for the next point if autoFlag: # Use a linear scaling based on ODE time vector tind = np.floor((len(time)/Narrows) * (j-k)) + k elif logtimeFlag: # Use an exponential time vector # MATLAB: tind = find(time < (j-k) / lambda, 1, 'last') tarr = _find(time < (j-k) / timefactor) tind = tarr[-1] if len(tarr) else 0 elif timeptsFlag: # Use specified time points # MATLAB: tind = find(time < Y[j], 1, 'last') tarr = _find(time < timepts[j]) tind = tarr[-1] if len(tarr) else 0 # For tailless arrows, skip the first point if tind == 0 and scale is None: continue # Figure out the arrow at this point on the curve x1[i,j] = state[tind, 0] x2[i,j] = state[tind, 1] # Skip arrows outside of initial condition box if (scale is not None or (x1[i,j] <= xmax and x1[i,j] >= xmin and x2[i,j] <= ymax and x2[i,j] >= ymin)): if tfirst: pass v = odefun(0, [x1[i,j], x2[i,j]], *params) else: v = odefun([x1[i,j], x2[i,j]], 0, *params) dx[i, j, 0] = v[0]; dx[i, j, 1] = v[1] else: dx[i, j, 0] = 0; dx[i, j, 1] = 0 # Set the plot shape before plotting arrows to avoid warping # a=gca # if (scale != None): # set(a,'DataAspectRatio', [1,1,1]) # if (xmin != xmax and ymin != ymax): # plt.axis([xmin, xmax, ymin, ymax]) # set(a, 'Box', 'on') # Plot arrows on the streamlines if scale is None and Narrows > 0: # Use a tailless arrow #! TODO: figure out arguments to make arrows show up correctly plt.quiver(x1, x2, dx[:,:,0], dx[:,:,1], angles='xy') elif scale != 0 and Narrows > 0: plt.quiver(x1, x2, dx[:,:,0]*abs(scale), dx[:,:,1]*abs(scale), angles='xy') #! TODO: figure out arguments to make arrows show up correctly # xy = plt.quiver(...) # set(xy, 'LineWidth', PP_arrow_linewidth) # set(xy, 'AutoScale', 'off') # set(xy, 'AutoScaleFactor', 0) if scale < 0: plt.plot(x1, x2, 'b.'); # add dots at base # bp = plt.plot(...) # set(bp, 'MarkerSize', PP_arrow_markersize) # Utility function for generating initial conditions around a box def box_grid(xlimp, ylimp): """Generate list of points on edge of box. .. deprecated:: 0.10.0 Use `phaseplot.boxgrid` instead. list = box_grid([xmin xmax xnum], [ymin ymax ynum]) generates a list of points that correspond to a uniform grid at the end of the box defined by the corners [xmin ymin] and [xmax ymax]. """ # Generate a deprecation warning warnings.warn( "box_grid() is deprecated; use phaseplot.boxgrid() instead", FutureWarning) return boxgrid( np.linspace(xlimp[0], xlimp[1], xlimp[2]), np.linspace(ylimp[0], ylimp[1], ylimp[2])) # TODO: rename to something more useful (or remove??) def _find(condition): """Returns indices where ravel(a) is true. Private implementation of deprecated `matplotlib.mlab.find`. """ return np.nonzero(np.ravel(condition))[0]