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

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

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


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy