diff --git a/control/phaseplot.py b/control/phaseplot.py index 5f5016f57..1a2ce6074 100644 --- a/control/phaseplot.py +++ b/control/phaseplot.py @@ -45,16 +45,19 @@ 'phaseplot.separatrices_radius': 0.1 # initial radius for separatrices } + def phase_plane_plot( sys, pointdata=None, timedata=None, gridtype=None, gridspec=None, - plot_streamlines=True, plot_vectorfield=False, plot_equilpoints=True, - plot_separatrices=True, ax=None, suppress_warnings=False, title=None, - **kwargs + 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 ---------- @@ -105,6 +108,7 @@ def phase_plane_plot( - 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. @@ -128,13 +132,17 @@ def phase_plane_plot( '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 (default) 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`. + 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 (default) 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`. + 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 @@ -151,7 +159,39 @@ def phase_plane_plot( 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) @@ -174,7 +214,10 @@ def _create_kwargs(global_kwargs, local_kwargs, **other_kwargs): return new_kwargs # Create list for storing outputs - out = np.array([[], None, None], dtype=object) + 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: @@ -185,6 +228,10 @@ def _create_kwargs(global_kwargs, local_kwargs, **other_kwargs): 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']: @@ -194,29 +241,60 @@ def _create_kwargs(global_kwargs, local_kwargs, **other_kwargs): if gridtype not in [None, 'boxgrid', 'meshgrid']: gridspec = None - if plot_separatrices: + if plot_vectorfield: kwargs_local = _create_kwargs( - kwargs, plot_separatrices, gridspec=gridspec, ax=ax) - out[0] += separatrices( + kwargs, plot_vectorfield, gridspec=gridspec, ax=ax) + out[1] = vectorfield( sys, pointdata, _check_kwargs=False, **kwargs_local) - # Get rid of keyword arguments handled by separatrices - for kw in ['arrows', 'arrow_size', 'arrow_style', 'params']: + 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_vectorfield: + if plot_streamplot: + if gridtype not in [None, 'meshgrid']: + raise ValueError( + "gridtype must be 'meshgrid' when using streamplot") + kwargs_local = _create_kwargs( - kwargs, plot_vectorfield, gridspec=gridspec, ax=ax) - out[1] = vectorfield( + kwargs, plot_streamplot, gridspec=gridspec, ax=ax) + out[3] = streamplot( sys, pointdata, _check_kwargs=False, **kwargs_local) - # Get rid of keyword arguments handled by vectorfield + 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) @@ -240,8 +318,8 @@ def _create_kwargs(global_kwargs, local_kwargs, **other_kwargs): def vectorfield( - sys, pointdata, gridspec=None, ax=None, suppress_warnings=False, - _check_kwargs=True, **kwargs): + 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 @@ -289,6 +367,9 @@ def vectorfield( 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 @@ -327,14 +408,127 @@ def vectorfield( with plt.rc_context(rcParams): out = ax.quiver( vfdata[:, 0], vfdata[:, 1], vfdata[:, 2], vfdata[:, 3], - angles='xy', color=color) + 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, - ax=None, _check_kwargs=True, suppress_warnings=False, **kwargs): + 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 @@ -399,6 +593,9 @@ def streamlines( 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 @@ -454,7 +651,7 @@ def streamlines( # 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) + out += ax.plot(traj[0], traj[1], color=color, zorder=zorder) # Add arrows to the lines at specified intervals _add_arrows_to_line2D( @@ -463,7 +660,7 @@ def streamlines( def equilpoints( - sys, pointdata, gridspec=None, color='k', ax=None, + sys, pointdata, gridspec=None, color='k', zorder=None, ax=None, _check_kwargs=True, **kwargs): """Plot equilibrium points in the phase plane. @@ -509,6 +706,9 @@ def equilpoints( 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 @@ -542,12 +742,13 @@ def equilpoints( out = [] for xeq in equilpts: with plt.rc_context(rcParams): - out += ax.plot(xeq[0], xeq[1], marker='o', color=color) + out += ax.plot( + xeq[0], xeq[1], marker='o', color=color, zorder=zorder) return out def separatrices( - sys, pointdata, timedata=None, gridspec=None, ax=None, + sys, pointdata, timedata=None, gridspec=None, zorder=None, ax=None, _check_kwargs=True, suppress_warnings=False, **kwargs): """Plot separatrices in the phase plane. @@ -603,6 +804,9 @@ def separatrices( 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 ----- @@ -663,10 +867,6 @@ def separatrices( # Plot separatrices by flowing backwards in time along eigenspaces out = [] for i, xeq in enumerate(equilpts): - # Plot the equilibrium points - with plt.rc_context(rcParams): - out += ax.plot(xeq[0], xeq[1], marker='o', color='k') - # Figure out the linearization and eigenvectors evals, evecs = np.linalg.eig(sys.linearize(xeq, 0, params=params).A) @@ -707,7 +907,8 @@ def separatrices( if traj.shape[1] > 1: with plt.rc_context(rcParams): out += ax.plot( - traj[0], traj[1], color=color, linestyle=linestyle) + traj[0], traj[1], color=color, + linestyle=linestyle, zorder=zorder) # Add arrows to the lines at specified intervals with plt.rc_context(rcParams): @@ -807,6 +1008,7 @@ def circlegrid(centers, radius, num): theta in np.linspace(0, 2 * math.pi, num, endpoint=False)]) return grid + # # Internal utility functions # @@ -827,6 +1029,7 @@ def _create_system(sys, params): 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 diff --git a/control/tests/ctrlplot_test.py b/control/tests/ctrlplot_test.py index b7192c844..221085328 100644 --- a/control/tests/ctrlplot_test.py +++ b/control/tests/ctrlplot_test.py @@ -116,6 +116,11 @@ def setup_plot_arguments(resp_fcn, plot_fcn, compute_time_response=True): args2 = (sys2, ) argsc = ([sys1, sys2], ) + case (None, ct.phase_plane_plot): + args1 = (sys1, ) + args2 = (sys2, ) + plot_kwargs = {'plot_streamlines': True} + case _, _: args1 = (sys1, ) args2 = (sys2, ) diff --git a/control/tests/kwargs_test.py b/control/tests/kwargs_test.py index 2e4919004..b2525a908 100644 --- a/control/tests/kwargs_test.py +++ b/control/tests/kwargs_test.py @@ -172,6 +172,7 @@ def test_unrecognized_kwargs(function, nsssys, ntfsys, moreargs, kwargs, (control.phase_plane_plot, 1, ([-1, 1, -1, 1], 1), {}), (control.phaseplot.streamlines, 1, ([-1, 1, -1, 1], 1), {}), (control.phaseplot.vectorfield, 1, ([-1, 1, -1, 1], ), {}), + (control.phaseplot.streamplot, 1, ([-1, 1, -1, 1], ), {}), (control.phaseplot.equilpoints, 1, ([-1, 1, -1, 1], ), {}), (control.phaseplot.separatrices, 1, ([-1, 1, -1, 1], ), {}), (control.singular_values_plot, 1, (), {})] @@ -360,6 +361,7 @@ def test_response_plot_kwargs(data_fcn, plot_fcn, mimo): optimal_test.test_oep_argument_errors, 'phaseplot.streamlines': test_matplotlib_kwargs, 'phaseplot.vectorfield': test_matplotlib_kwargs, + 'phaseplot.streamplot': test_matplotlib_kwargs, 'phaseplot.equilpoints': test_matplotlib_kwargs, 'phaseplot.separatrices': test_matplotlib_kwargs, } diff --git a/control/tests/phaseplot_test.py b/control/tests/phaseplot_test.py index 106dee6f0..18c163582 100644 --- a/control/tests/phaseplot_test.py +++ b/control/tests/phaseplot_test.py @@ -12,6 +12,7 @@ import warnings from math import pi +import matplotlib as mpl import matplotlib.pyplot as plt import numpy as np import pytest @@ -138,29 +139,39 @@ def invpend_ode(t, x, m=0, l=0, b=0, g=0): # Use callable form, with parameters (if not correct, will get /0 error) ct.phase_plane_plot( - invpend_ode, [-5, 5, -2, 2], params={'args': (1, 1, 0.2, 1)}) + invpend_ode, [-5, 5, -2, 2], params={'args': (1, 1, 0.2, 1)}, + plot_streamlines=True) # Linear I/O system ct.phase_plane_plot( - ct.ss([[0, 1], [-1, -1]], [[0], [1]], [[1, 0]], 0)) + ct.ss([[0, 1], [-1, -1]], [[0], [1]], [[1, 0]], 0), + plot_streamlines=True) @pytest.mark.usefixtures('mplcleanup') def test_phaseplane_errors(): with pytest.raises(ValueError, match="invalid grid specification"): - ct.phase_plane_plot(ct.rss(2, 1, 1), gridspec='bad') + ct.phase_plane_plot(ct.rss(2, 1, 1), gridspec='bad', + plot_streamlines=True) with pytest.raises(ValueError, match="unknown grid type"): - ct.phase_plane_plot(ct.rss(2, 1, 1), gridtype='bad') + ct.phase_plane_plot(ct.rss(2, 1, 1), gridtype='bad', + plot_streamlines=True) with pytest.raises(ValueError, match="system must be planar"): - ct.phase_plane_plot(ct.rss(3, 1, 1)) + ct.phase_plane_plot(ct.rss(3, 1, 1), + plot_streamlines=True) with pytest.raises(ValueError, match="params must be dict with key"): def invpend_ode(t, x, m=0, l=0, b=0, g=0): return (x[1], -b/m*x[1] + (g*l/m) * np.sin(x[0])) ct.phase_plane_plot( - invpend_ode, [-5, 5, 2, 2], params={'stuff': (1, 1, 0.2, 1)}) + invpend_ode, [-5, 5, 2, 2], params={'stuff': (1, 1, 0.2, 1)}, + plot_streamlines=True) + + with pytest.raises(ValueError, match="gridtype must be 'meshgrid' when using streamplot"): + ct.phase_plane_plot(ct.rss(2, 1, 1), plot_streamlines=False, + plot_streamplot=True, gridtype='boxgrid') # Warning messages for invalid solutions: nonlinear spring mass system sys = ct.nlsys( @@ -170,14 +181,87 @@ def invpend_ode(t, x, m=0, l=0, b=0, g=0): with pytest.warns(UserWarning, match=r"X0=array\(.*\), solve_ivp failed"): ct.phase_plane_plot( sys, [-12, 12, -10, 10], 15, gridspec=[2, 9], - plot_separatrices=False) + plot_separatrices=False, plot_streamlines=True) # Turn warnings off with warnings.catch_warnings(): warnings.simplefilter("error") ct.phase_plane_plot( sys, [-12, 12, -10, 10], 15, gridspec=[2, 9], - plot_separatrices=False, suppress_warnings=True) + plot_streamlines=True, plot_separatrices=False, + suppress_warnings=True) + +@pytest.mark.usefixtures('mplcleanup') +def test_phase_plot_zorder(): + # some of these tests are a bit akward since the streamlines and separatrices + # are stored in the same list, so we separate them by color + key_color = "tab:blue" # must not be 'k', 'r', 'b' since they are used by separatrices + + def get_zorders(cplt): + max_zorder = lambda items: max([line.get_zorder() for line in items]) + assert isinstance(cplt.lines[0], list) + streamline_lines = [line for line in cplt.lines[0] if line.get_color() == key_color] + separatrice_lines = [line for line in cplt.lines[0] if line.get_color() != key_color] + streamlines = max_zorder(streamline_lines) if streamline_lines else None + separatrices = max_zorder(separatrice_lines) if separatrice_lines else None + assert cplt.lines[1] == None or isinstance(cplt.lines[1], mpl.quiver.Quiver) + quiver = cplt.lines[1].get_zorder() if cplt.lines[1] else None + assert cplt.lines[2] == None or isinstance(cplt.lines[2], list) + equilpoints = max_zorder(cplt.lines[2]) if cplt.lines[2] else None + assert cplt.lines[3] == None or isinstance(cplt.lines[3], mpl.streamplot.StreamplotSet) + streamplot = max(cplt.lines[3].lines.get_zorder(), cplt.lines[3].arrows.get_zorder()) if cplt.lines[3] else None + return streamlines, quiver, streamplot, separatrices, equilpoints + + def assert_orders(streamlines, quiver, streamplot, separatrices, equilpoints): + print(streamlines, quiver, streamplot, separatrices, equilpoints) + if streamlines is not None: + assert streamlines < separatrices < equilpoints + if quiver is not None: + assert quiver < separatrices < equilpoints + if streamplot is not None: + assert streamplot < separatrices < equilpoints + + def sys(t, x): + return np.array([4*x[1], -np.sin(4*x[0])]) + + # ensure correct zordering for all three flow types + res_streamlines = ct.phase_plane_plot(sys, plot_streamlines=dict(color=key_color)) + assert_orders(*get_zorders(res_streamlines)) + res_vectorfield = ct.phase_plane_plot(sys, plot_vectorfield=True) + assert_orders(*get_zorders(res_vectorfield)) + res_streamplot = ct.phase_plane_plot(sys, plot_streamplot=True) + assert_orders(*get_zorders(res_streamplot)) + + # ensure that zorder can still be overwritten + res_reversed = ct.phase_plane_plot(sys, plot_streamlines=dict(color=key_color, zorder=50), plot_vectorfield=dict(zorder=40), + plot_streamplot=dict(zorder=30), plot_separatrices=dict(zorder=20), plot_equilpoints=dict(zorder=10)) + streamlines, quiver, streamplot, separatrices, equilpoints = get_zorders(res_reversed) + assert streamlines > quiver > streamplot > separatrices > equilpoints + + +@pytest.mark.usefixtures('mplcleanup') +def test_stream_plot_magnitude(): + def sys(t, x): + return np.array([4*x[1], -np.sin(4*x[0])]) + + # plt context with linewidth + with plt.rc_context({'lines.linewidth': 4}): + res = ct.phase_plane_plot(sys, plot_streamplot=dict(vary_linewidth=True)) + linewidths = res.lines[3].lines.get_linewidths() + # linewidths are scaled to be between 0.25 and 2 times default linewidth + # but the extremes may not exist if there is no line at that point + assert min(linewidths) < 2 and max(linewidths) > 7 + + # make sure changing the colormap works + res = ct.phase_plane_plot(sys, plot_streamplot=dict(vary_color=True, cmap='viridis')) + assert res.lines[3].lines.get_cmap().name == 'viridis' + res = ct.phase_plane_plot(sys, plot_streamplot=dict(vary_color=True, cmap='turbo')) + assert res.lines[3].lines.get_cmap().name == 'turbo' + + # make sure changing the norm at least doesn't throw an error + ct.phase_plane_plot(sys, plot_streamplot=dict(vary_color=True, norm=mpl.colors.LogNorm())) + + @pytest.mark.usefixtures('mplcleanup') @@ -189,7 +273,7 @@ def test_basic_phase_plots(savefigs=False): plt.figure() axis_limits = [-1, 1, -1, 1] T = 8 - ct.phase_plane_plot(sys, axis_limits, T) + ct.phase_plane_plot(sys, axis_limits, T, plot_streamlines=True) if savefigs: plt.savefig('phaseplot-dampedosc-default.png') @@ -202,7 +286,7 @@ def invpend_update(t, x, u, params): ct.phase_plane_plot( invpend, [-2*pi, 2*pi, -2, 2], 5, gridtype='meshgrid', gridspec=[5, 8], arrows=3, - plot_separatrices={'gridspec': [12, 9]}, + plot_separatrices={'gridspec': [12, 9]}, plot_streamlines=True, params={'m': 1, 'l': 1, 'b': 0.2, 'g': 1}) plt.xlabel(r"$\theta$ [rad]") plt.ylabel(r"$\dot\theta$ [rad/sec]") @@ -217,7 +301,8 @@ def oscillator_update(t, x, u, params): oscillator_update, states=2, inputs=0, name='nonlinear oscillator') plt.figure() - ct.phase_plane_plot(oscillator, [-1.5, 1.5, -1.5, 1.5], 0.9) + ct.phase_plane_plot(oscillator, [-1.5, 1.5, -1.5, 1.5], 0.9, + plot_streamlines=True) pp.streamlines( oscillator, np.array([[0, 0]]), 1.5, gridtype='circlegrid', gridspec=[0.5, 6], dir='both') @@ -227,6 +312,18 @@ def oscillator_update(t, x, u, params): if savefigs: plt.savefig('phaseplot-oscillator-helpers.png') + plt.figure() + ct.phase_plane_plot( + invpend, [-2*pi, 2*pi, -2, 2], + plot_streamplot=dict(vary_color=True, vary_density=True), + gridspec=[60, 20], params={'m': 1, 'l': 1, 'b': 0.2, 'g': 1} + ) + plt.xlabel(r"$\theta$ [rad]") + plt.ylabel(r"$\dot\theta$ [rad/sec]") + + if savefigs: + plt.savefig('phaseplot-invpend-streamplot.png') + if __name__ == "__main__": # diff --git a/doc/figures/phaseplot-dampedosc-default.png b/doc/figures/phaseplot-dampedosc-default.png index 69a28254f..3841fce83 100644 Binary files a/doc/figures/phaseplot-dampedosc-default.png and b/doc/figures/phaseplot-dampedosc-default.png differ diff --git a/doc/figures/phaseplot-invpend-meshgrid.png b/doc/figures/phaseplot-invpend-meshgrid.png index 118c364be..0d73f967c 100644 Binary files a/doc/figures/phaseplot-invpend-meshgrid.png and b/doc/figures/phaseplot-invpend-meshgrid.png differ diff --git a/doc/figures/phaseplot-oscillator-helpers.png b/doc/figures/phaseplot-oscillator-helpers.png index 829d94d74..ab1bb62a3 100644 Binary files a/doc/figures/phaseplot-oscillator-helpers.png and b/doc/figures/phaseplot-oscillator-helpers.png differ diff --git a/doc/functions.rst b/doc/functions.rst index db9a5a08c..d657fd431 100644 --- a/doc/functions.rst +++ b/doc/functions.rst @@ -103,6 +103,7 @@ Phase plane plots phaseplot.separatrices phaseplot.streamlines phaseplot.vectorfield + phaseplot.streamplot Frequency Response diff --git a/doc/phaseplot.rst b/doc/phaseplot.rst index 324b3baf4..d2a3e6353 100644 --- a/doc/phaseplot.rst +++ b/doc/phaseplot.rst @@ -12,7 +12,7 @@ functionality is supported by a set of mapping functions that are part of the `phaseplot` module. The default method for generating a phase plane plot is to provide a -2D dynamical system along with a range of coordinates and time limit: +2D dynamical system along with a range of coordinates in phase space: .. testsetup:: phaseplot @@ -27,8 +27,7 @@ The default method for generating a phase plane plot is to provide a sys_update, states=['position', 'velocity'], inputs=0, name='damped oscillator') axis_limits = [-1, 1, -1, 1] - T = 8 - ct.phase_plane_plot(sys, axis_limits, T) + ct.phase_plane_plot(sys, axis_limits) .. testcode:: phaseplot :hide: @@ -39,12 +38,12 @@ The default method for generating a phase plane plot is to provide a .. image:: figures/phaseplot-dampedosc-default.png :align: center -By default, the plot includes streamlines generated from starting -points on limits of the plot, with arrows showing the flow of the -system, as well as any equilibrium points for the system. A variety +By default the plot includes streamlines infered from function values +on a grid, equilibrium points and separatrices if they exist. A variety of options are available to modify the information that is plotted, -including plotting a grid of vectors instead of streamlines and -turning on and off various features of the plot. +including plotting a grid of vectors instead of streamlines, plotting +streamlines from arbitrary starting points and turning on and off +various features of the plot. To illustrate some of these possibilities, consider a phase plane plot for an inverted pendulum system, which is created using a mesh grid: @@ -62,9 +61,7 @@ an inverted pendulum system, which is created using a mesh grid: invpend = ct.nlsys(invpend_update, states=2, inputs=1, name='invpend') ct.phase_plane_plot( - invpend, [-2 * np.pi, 2 * np.pi, -2, 2], 5, - gridtype='meshgrid', gridspec=[5, 8], arrows=3, - plot_equilpoints={'gridspec': [12, 9]}, + invpend, [-2 * np.pi, 2 * np.pi, -2, 2], params={'m': 1, 'l': 1, 'b': 0.2, 'g': 1}) plt.xlabel(r"$\theta$ [rad]") plt.ylabel(r"$\dot\theta$ [rad/sec]") @@ -79,16 +76,17 @@ an inverted pendulum system, which is created using a mesh grid: This figure shows several features of more complex phase plane plots: multiple equilibrium points are shown, with saddle points showing -separatrices, and streamlines generated along a 5x8 mesh of initial -conditions. At each mesh point, a streamline is created that goes 5 time -units forward and backward in time. A separate grid specification is used -to find equilibrium points and separatrices (since the course grid spacing -of 5x8 does not find all possible equilibrium points). Together, the -multiple features in the phase plane plot give a good global picture of the +separatrices, and streamlines generated generated from a rectangular +25x25 grid (default) of function evaluations. Together, the multiple +features in the phase plane plot give a good global picture of the topological structure of solutions of the dynamical system. -Phase plots can be built up by hand using a variety of helper functions that -are part of the :mod:`phaseplot` (pp) module: +Phase plots can be built up by hand using a variety of helper +functions that are part of the :mod:`phaseplot` (pp) module. For more +precise control, the streamlines can also generated by integrating the +system forwards or backwards in time from a set of initial +conditions. The initial conditions can be chosen on a rectangular +grid, rectangual boundary, circle or from an arbitrary set of points. .. testcode:: phaseplot :hide: @@ -105,7 +103,8 @@ are part of the :mod:`phaseplot` (pp) module: oscillator = ct.nlsys( oscillator_update, states=2, inputs=0, name='nonlinear oscillator') - ct.phase_plane_plot(oscillator, [-1.5, 1.5, -1.5, 1.5], 0.9) + ct.phase_plane_plot(oscillator, [-1.5, 1.5, -1.5, 1.5], 0.9, + plot_streamlines=True) pp.streamlines( oscillator, np.array([[0, 0]]), 1.5, gridtype='circlegrid', gridspec=[0.5, 6], dir='both') @@ -128,6 +127,7 @@ The following helper functions are available: phaseplot.equilpoints phaseplot.separatrices phaseplot.streamlines + phaseplot.streamplot phaseplot.vectorfield The :func:`phase_plane_plot` function calls these helper functions diff --git a/examples/phase_plane_plots.py b/examples/phase_plane_plots.py index b3b2a01c3..db989d5d9 100644 --- a/examples/phase_plane_plots.py +++ b/examples/phase_plane_plots.py @@ -15,6 +15,9 @@ import control as ct import control.phaseplot as pp +# Set default plotting parameters to match ControlPlot +plt.rcParams.update(ct.rcParams) + # # Example 1: Dampled oscillator systems # @@ -35,16 +38,18 @@ def damposc_update(t, x, u, params): ct.phase_plane_plot(damposc, [-1, 1, -1, 1], 8, ax=ax1) ax1.set_title("boxgrid [-1, 1, -1, 1], 8") -ct.phase_plane_plot(damposc, [-1, 1, -1, 1], ax=ax2, gridtype='meshgrid') -ax2.set_title("meshgrid [-1, 1, -1, 1]") +ct.phase_plane_plot(damposc, [-1, 1, -1, 1], ax=ax2, plot_streamlines=True, + gridtype='meshgrid') +ax2.set_title("streamlines, meshgrid [-1, 1, -1, 1]") ct.phase_plane_plot( - damposc, [-1, 1, -1, 1], 4, ax=ax3, gridtype='circlegrid', dir='both') -ax3.set_title("circlegrid [0, 0, 1], 4, both") + damposc, [-1, 1, -1, 1], 4, ax=ax3, plot_streamlines=True, + gridtype='circlegrid', dir='both') +ax3.set_title("streamlines, circlegrid [0, 0, 1], 4, both") ct.phase_plane_plot( damposc, [-1, 1, -1, 1], ax=ax4, gridtype='circlegrid', - dir='reverse', gridspec=[0.1, 12], timedata=5) + plot_streamlines=True, dir='reverse', gridspec=[0.1, 12], timedata=5) ax4.set_title("circlegrid [0, 0, 0.1], reverse") # @@ -67,17 +72,19 @@ def invpend_update(t, x, u, params): ax1.set_title("default, 5") ct.phase_plane_plot( - invpend, [-2*pi, 2*pi, -2, 2], gridtype='meshgrid', ax=ax2) -ax2.set_title("meshgrid") + invpend, [-2*pi, 2*pi, -2, 2], gridtype='meshgrid', ax=ax2, + plot_streamlines=True) +ax2.set_title("streamlines, meshgrid") ct.phase_plane_plot( invpend, [-2*pi, 2*pi, -2, 2], 1, gridtype='meshgrid', - gridspec=[12, 9], ax=ax3, arrows=1) -ax3.set_title("denser grid") + gridspec=[12, 9], ax=ax3, arrows=1, plot_streamlines=True) +ax3.set_title("streamlines, denser grid") ct.phase_plane_plot( invpend, [-2*pi, 2*pi, -2, 2], 4, gridspec=[6, 6], - plot_separatrices={'timedata': 20, 'arrows': 4}, ax=ax4) + plot_separatrices={'timedata': 20, 'arrows': 4}, ax=ax4, + plot_streamlines=True) ax4.set_title("custom") # @@ -102,21 +109,22 @@ def oscillator_update(t, x, u, params): try: ct.phase_plane_plot( oscillator, [-1.5, 1.5, -1.5, 1.5], 1, gridtype='meshgrid', - dir='forward', ax=ax2) + dir='forward', ax=ax2, plot_streamlines=True) except RuntimeError as inst: - axs[0,1].text(0, 0, "Runtime Error") + ax2.text(0, 0, "Runtime Error") warnings.warn(inst.__str__()) -ax2.set_title("meshgrid, forward, 0.5") +ax2.set_title("streamlines, meshgrid, forward, 0.5") ax2.set_aspect('equal') -ct.phase_plane_plot(oscillator, [-1.5, 1.5, -1.5, 1.5], ax=ax3) +ct.phase_plane_plot(oscillator, [-1.5, 1.5, -1.5, 1.5], ax=ax3, + plot_streamlines=True) pp.streamlines( oscillator, [-0.5, 0.5, -0.5, 0.5], dir='both', ax=ax3) -ax3.set_title("outer + inner") +ax3.set_title("streamlines, outer + inner") ax3.set_aspect('equal') ct.phase_plane_plot( - oscillator, [-1.5, 1.5, -1.5, 1.5], 0.9, ax=ax4) + oscillator, [-1.5, 1.5, -1.5, 1.5], 0.9, ax=ax4, plot_streamlines=True) pp.streamlines( oscillator, np.array([[0, 0]]), 1.5, gridtype='circlegrid', gridspec=[0.5, 6], dir='both', ax=ax4) @@ -141,8 +149,9 @@ def saddle_update(t, x, u, params): ax1.set_title("default") ct.phase_plane_plot( - saddle, [-1, 1, -1, 1], 0.5, gridtype='meshgrid', ax=ax2) -ax2.set_title("meshgrid") + saddle, [-1, 1, -1, 1], 0.5, plot_streamlines=True, gridtype='meshgrid', + ax=ax2) +ax2.set_title("streamlines, meshgrid") ct.phase_plane_plot( saddle, [-1, 1, -1, 1], gridspec=[16, 12], ax=ax3, @@ -150,9 +159,9 @@ def saddle_update(t, x, u, params): ax3.set_title("vectorfield") ct.phase_plane_plot( - saddle, [-1, 1, -1, 1], 0.3, + saddle, [-1, 1, -1, 1], 0.3, plot_streamlines=True, gridtype='meshgrid', gridspec=[5, 7], ax=ax4) -ax3.set_title("custom") +ax4.set_title("custom") # # Example 5: Internet congestion control @@ -172,6 +181,7 @@ def _congctrl_update(t, x, u, params): return np.append( c / x[M] - (rho * c) * (1 + (x[:-1]**2) / 2), N/M * np.sum(x[:-1]) * c / x[M] - c) + congctrl = ct.nlsys( _congctrl_update, states=2, inputs=0, params={'N': 60, 'rho': 2e-4, 'c': 10}) @@ -203,7 +213,7 @@ def _congctrl_update(t, x, u, params): ax3.set_title("vector field") ct.phase_plane_plot( - congctrl, [2, 6, 200, 300], 100, + congctrl, [2, 6, 200, 300], 100, plot_streamlines=True, params={'rho': 4e-4, 'c': 20}, ax=ax4, plot_vectorfield={'gridspec': [12, 9]}) ax4.set_title("vector field + streamlines") diff --git a/examples/plot_gallery.py b/examples/plot_gallery.py index 5d7163952..d7876d78f 100644 --- a/examples/plot_gallery.py +++ b/examples/plot_gallery.py @@ -102,7 +102,6 @@ def invpend_update(t, x, u, params): invpend = ct.nlsys(invpend_update, states=2, inputs=1, name='invpend') ct.phase_plane_plot( invpend, [-2*pi, 2*pi, -2, 2], 5, - gridtype='meshgrid', gridspec=[5, 8], arrows=3, plot_separatrices={'gridspec': [12, 9]}, params={'m': 1, 'l': 1, 'b': 0.2, 'g': 1})