diff --git a/control/__init__.py b/control/__init__.py index 5a9e05e95..45f2a56d6 100644 --- a/control/__init__.py +++ b/control/__init__.py @@ -63,6 +63,7 @@ * :mod:`~control.flatsys`: Differentially flat systems * :mod:`~control.matlab`: MATLAB compatibility module * :mod:`~control.optimal`: Optimization-based control +* :mod:`~control.phaseplot`: 2D phase plane diagrams """ @@ -103,6 +104,10 @@ from .passivity import * from .sysnorm import * +# Allow access to phase_plane functions as ct.phaseplot.fcn or ct.pp.fcn +from . import phaseplot +from . import phaseplot as pp + # Exceptions from .exception import * diff --git a/control/config.py b/control/config.py index 0ae883f49..b6d5385d4 100644 --- a/control/config.py +++ b/control/config.py @@ -152,6 +152,9 @@ def reset_defaults(): from .timeplot import _timeplot_defaults defaults.update(_timeplot_defaults) + from .phaseplot import _phaseplot_defaults + defaults.update(_phaseplot_defaults) + def _get_param(module, param, argval=None, defval=None, pop=False, last=False): """Return the default value for a configuration option. diff --git a/control/flatsys/__init__.py b/control/flatsys/__init__.py index cd77dc39a..c6934d825 100644 --- a/control/flatsys/__init__.py +++ b/control/flatsys/__init__.py @@ -35,8 +35,10 @@ # Author: Richard M. Murray # Date: 1 Jul 2019 -r"""The :mod:`control.flatsys` package contains a set of classes and functions -that can be used to compute trajectories for differentially flat systems. +r"""Differentially flat systems sub-package. + +The :mod:`control.flatsys` sub-package contains a set of classes and +functions to compute trajectories for differentially flat systems. A differentially flat system is defined by creating an object using the :class:`~control.flatsys.FlatSystem` class, which has member functions for diff --git a/control/freqplot.py b/control/freqplot.py index c147c5e67..961f499b3 100644 --- a/control/freqplot.py +++ b/control/freqplot.py @@ -1070,7 +1070,7 @@ def gen_zero_centered_series(val_min, val_max, period): _nyquist_defaults = { 'nyquist.primary_style': ['-', '-.'], # style for primary curve 'nyquist.mirror_style': ['--', ':'], # style for mirror curve - 'nyquist.arrows': 2, # number of arrors around curve + 'nyquist.arrows': 2, # number of arrows around curve 'nyquist.arrow_size': 8, # pixel size for arrows 'nyquist.encirclement_threshold': 0.05, # warning threshold 'nyquist.indent_radius': 1e-4, # indentation radius diff --git a/control/phaseplot.py b/control/phaseplot.py index a32383fb8..d785a2221 100644 --- a/control/phaseplot.py +++ b/control/phaseplot.py @@ -1,61 +1,932 @@ -#! TODO: add module docstring # phaseplot.py - generate 2D phase portraits # # Author: Richard M. Murray -# Date: 24 July 2011, converted from MATLAB version (2002); based on -# a version by Kristi Morgansen +# Date: 23 Mar 2024 (legacy version information below) # -# Copyright (c) 2011 by California Institute of Technology -# All rights reserved. -# -# Redistribution and use in source and binary forms, with or without -# modification, are permitted provided that the following conditions are -# met: +# TODO +# * Allow multiple timepoints (and change timespec name to T?) +# * Update linestyles (color -> linestyle?) +# * Check for keyword compatibility with other plot routines +# * Set up configuration parameters (nyquist --> phaseplot) + +"""Module for generating 2D phase plane plots. + +The :mod:`control.phaseplot` module contains functions for generating 2D +phase plots. The base function for creating phase plane portraits is +:func:`~control.phase_plane_plot`, which generates a phase plane portrait +for a 2 state I/O system (with no inputs). In addition, several other +functions are available to create customized phase plane plots: + +* boxgrid: Generate a list of points along the edge of a box +* circlegrid: Generate list of points around a circle +* equilpoints: Plot equilibrium points in the phase plane +* meshgrid: Generate a list of points forming a mesh +* separatrices: Plot separatrices in the phase plane +* streamlines: Plot stream lines in the phase plane +* vectorfield: Plot a vector field in the phase plane + +""" + +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 .exception import ControlNotImplemented +from .freqplot import _add_arrows_to_line2D +from .nlsys import NonlinearIOSystem, find_eqpt, 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.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, **kwargs +): + """Plot phase plane diagram. + + This function plots phase plane data, including vector fields, stream + lines, equilibrium points, and contour curves. + + 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). + plot_streamlines : bool or dict + 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 :func:`~control.phaseplot.streamlines`. + plot_vectorfield : bool or dict + 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 :func:`~control.phaseplot.vectorfield`. + plot_equilpoints : bool or dict + 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 :func:`~control.phaseplot.equilpoints`. + plot_separatrices : bool or dict + 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 :func:`~control.phaseplot.separatrices`. + color : str + Plot all elements in the given color (use `plot_={'color': c}` + to set the color in one element of the phase plot. + ax : Axes + Use the given axes for the plot instead of creating a new figure. + + Returns + ------- + out : list of list of Artists + out[0] = list of Line2D objects (streamlines and separatrices) + out[1] = Quiver object (vector field arrows) + out[2] = list of Line2D objects (equilibrium points) + + """ + # Process arguments + params = kwargs.get('params', None) + sys = _create_system(sys, params) + pointdata = [-1, 1, -1, 1] if pointdata is None else pointdata + + # Create axis if needed + if ax is None: + fig, ax = plt.gcf(), plt.gca() + else: + fig = None # don't modify figure + + # 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 = [[], None, 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, **kwargs_local) + + # 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_separatrices: + kwargs_local = _create_kwargs( + kwargs, plot_separatrices, gridspec=gridspec, ax=ax) + out[0] += separatrices( + sys, pointdata, check_kwargs=False, **kwargs_local) + + # Get rid of keyword arguments handled by separatrices + for kw in ['arrows', 'arrow_size', 'arrow_style', 'params']: + initial_kwargs.pop(kw, 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) + + # Get rid of keyword arguments handled by vectorfield + for kw in ['color', 'params']: + initial_kwargs.pop(kw, None) + + if plot_equilpoints: + kwargs_local = _create_kwargs( + kwargs, plot_equilpoints, gridspec=gridspec, ax=ax) + 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 fig is not None: + fig.suptitle(f"Phase portrait for {sys.name}") + ax.set_xlabel(sys.state_labels[0]) + ax.set_ylabel(sys.state_labels[1]) + + return out + + +def vectorfield( + sys, pointdata, gridspec=None, ax=None, 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 : str + Plot the vector field in the given color. + ax : Axes + Use the given axes for the plot, otherwise use the current axes. + + Returns + ------- + out : Quiver + + """ + # 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) + + # 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, 0) + + out = ax.quiver( + vfdata[:, 0], vfdata[:, 1], vfdata[:, 2], vfdata[:, 3], + angles='xy', color=color) + + return out + + +def streamlines( + sys, pointdata, timedata=1, gridspec=None, gridtype=None, + dir=None, ax=None, check_kwargs=True, **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. + 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 : Axes + Use the given axes for the plot, otherwise use the current axes. + + Returns + ------- + out : list of Line2D objects + + """ + # 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) + + # 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) + + # Plot the trajectory + if traj.shape[1] > 1: + out.append( + ax.plot(traj[0], traj[1], color=color)) + + # Add arrows to the lines at specified intervals + _add_arrows_to_line2D( + ax, out[-1][0], arrow_pos, arrowstyle=arrow_style, dir=1) + + return out + + +def equilpoints( + sys, pointdata, gridspec=None, color='k', 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 : Axes + Use the given axes for the plot, otherwise use the current axes. + + Returns + ------- + out : list of Line2D objects + + """ + # 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: + out.append( + ax.plot(xeq[0], xeq[1], marker='o', color=color)) + + return out + + +def separatrices( + sys, pointdata, timedata=None, gridspec=None, ax=None, + check_kwargs=True, **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 : str + Plot the streamlines in the given color. + ax : Axes + Use the given axes for the plot, otherwise use the current axes. + + Returns + ------- + out : list of Line2D objects + + """ + # 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 = 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): + # Plot the equilibrium points + out.append( + 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) + + # 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) + color = stable_color + linestyle = '--' + elif evals[j].real > 0: + traj = _create_trajectory( + sys, revsys, timepts, x0, params, 'forward', + gridtype='boxgrid', xlim=xlim, ylim=ylim) + color = unstable_color + linestyle = '-' + + if traj.shape[1] > 1: + out.append(ax.plot( + traj[0], traj[1], color=color, linestyle=linestyle)) + + # Add arrows to the lines at specified intervals + _add_arrows_to_line2D( + ax, out[-1][0], arrow_pos, arrowstyle=arrow_style, + dir=1) + + return out + + # -# 1. Redistributions of source code must retain the above copyright -# notice, this list of conditions and the following disclaimer. +# User accessible utility functions # -# 2. Redistributions in binary form must reproduce the above copyright -# notice, this list of conditions and the following disclaimer in the -# documentation and/or other materials provided with the distribution. + +# 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 + # -# 3. The name of the author may not be used to endorse or promote products -# derived from this software without specific prior written permission. +# Internal utility functions # -# THIS SOFTWARE IS PROVIDED BY THE AUTHOR ``AS IS'' AND ANY EXPRESS OR -# IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED -# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE -# DISCLAIMED. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY DIRECT, -# INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES -# (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR -# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) -# HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, -# STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING -# IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE -# POSSIBILITY OF SUCH DAMAGE. -import numpy as np -import matplotlib.pyplot as mpl +# 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 -from scipy.integrate import odeint -from .exception import ControlNotImplemented + # 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'") -__all__ = ['phase_plot', 'box_grid'] + _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 -def _find(condition): - """Returns indices where ravel(a) is true. - Private implementation of deprecated matplotlib.mlab.find - """ - return np.nonzero(np.ravel(condition))[0] +# 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_eqpt(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 + + +def _get_color(kwargs, ax=None): + if 'color' in kwargs: + return kwargs.pop('color') + + # If we were passed an axis, try to increment color from previous + color_cycle = plt.rcParams['axes.prop_cycle'].by_key()['color'] + if ax is not None: + color_offset = 0 + if len(ax.lines) > 0: + last_color = ax.lines[-1].get_color() + if last_color in color_cycle: + color_offset = color_cycle.index(last_color) + 1 + return color_cycle[color_offset % len(color_cycle)] + else: + return None + + +def _create_trajectory( + sys, revsys, timepts, X0, params, dir, + gridtype=None, gridspec=None, xlim=None, ylim=None): + # Comput ethe forward trajectory + if dir == 'forward' or dir == 'both': + fwdresp = input_output_response(sys, timepts, X0=X0, params=params) + + # Compute the reverse trajectory + if dir == 'reverse' or dir == 'both': + revresp = input_output_response( + revsys, timepts, X0=X0, params=params) + + # 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]) + + return traj + + +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=(), verbose=True): - """Phase plot for 2D dynamical systems. + lingrid=None, lintime=None, logtime=None, timepts=None, + parms=None, params=(), tfirst=False, verbose=True): - Produces a vector field or stream line plot for a planar system. + """(legacy) Phase plot for 2D dynamical systems. + + Produces a vector field or stream line plot for a planar system. This + function has been replaced by the :func:`~control.phase_plane_map` and + :func:`~control.phase_plane_plot` functions. Call signatures: phase_plot(func, X, Y, ...) - display vector field on meshgrid @@ -68,54 +939,52 @@ def phase_plot(odefun, X=None, Y=None, scale=1, X0=None, T=None, Parameters ---------- func : callable(x, t, ...) - Computes the time derivative of y (compatible with odeint). - The function should be the same for as used for - :mod:`scipy.integrate`. Namely, it should be a function of the form - dxdt = F(x, t) that accepts a state x of dimension 2 and - returns a derivative dx/dt of dimension 2. - + Computes the time derivative of y (compatible with odeint). The + function should be the same for as used for :mod:`scipy.integrate`. + Namely, it should be a function of the form dxdt = 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, ...] - - parms: tuple, optional - List of parameters to pass to vector field: `func(x, t, *parms)` + 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 : construct box-shaped grid of initial conditions """ + # Generate a deprecation warning + warnings.warn( + "phase_plot is deprecated; use phase_plot_plot instead", + FutureWarning) # # Figure out ranges for phase plot (argument processing) @@ -123,72 +992,89 @@ def phase_plot(odefun, X=None, Y=None, scale=1, X0=None, T=None, #! 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; + autoFlag = False + logtimeFlag = False + timeptsFlag = False + Narrows = 0 + + # Get parameters to pass to function + if parms: + warnings.warn( + f"keyword 'parms' is deprecated; use 'params'", FutureWarning) + if params: + raise ControlArgument(f"duplicate keywords 'parms' and 'params'") + else: + params = parms if lingrid is not None: - autoFlag = True; - Narrows = lingrid; + autoFlag = True + Narrows = lingrid if (verbose): print('Using auto arrows\n') elif logtime is not None: - logtimeFlag = True; - Narrows = logtime[0]; - timefactor = logtime[1]; + 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); + 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( + 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; + Narrows = 0 - if ((not autoFlag) and (not logtimeFlag) and (not timeptsFlag) - and (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; + (nr,nc) = x1.shape dx = np.empty((nr, nc, 2)) for i in range(nr): for j in range(nc): - dx[i, j, :] = np.squeeze(odefun((x1[i,j], x2[i,j]), 0, *parms)) + 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: - mpl.quiver(x1, x2, dx[:,:,1], dx[:,:,2], angles='xy') + plt.quiver(x1, x2, dx[:,:,1], dx[:,:,2], angles='xy') elif (scale != 0): #! TODO: optimize parameters for arrows #! TODO: figure out arguments to make arrows show up correctly - xy = mpl.quiver(x1, x2, dx[:,:,0]*np.abs(scale), + xy = plt.quiver(x1, x2, dx[:,:,0]*np.abs(scale), dx[:,:,1]*np.abs(scale), angles='xy') - # set(xy, 'LineWidth', PP_arrow_linewidth, 'Color', 'b'); + # 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)); - mpl.xlabel('x1'); mpl.ylabel('x2'); + # 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); + 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)); + x1 = np.empty((nr, Narrows)) + x2 = np.empty((nr, Narrows)) dx = np.empty((nr, Narrows, 2)) # See if we were passed a simulation time @@ -196,98 +1082,101 @@ def phase_plot(odefun, X=None, Y=None, scale=1, X0=None, T=None, T = 50 # Parse the time we were passed - TSPAN = T; - if (isinstance(T, (int, float))): - TSPAN = np.linspace(0, T, 100); + 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 = mpl.axis(); - xmin = alim[0]; xmax = alim[1]; - ymin = alim[2]; ymax = alim[3]; + 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]); + 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=parms); + state = odeint(odefun, X0[i], TSPAN, args=params, tfirst=tfirst) time = TSPAN - mpl.plot(state[:,0], state[:,1]) + 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); + # 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): + 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; + k = -1 if scale is None else 0 # Figure out what time index to use for the next point - if (autoFlag): + if autoFlag: # Use a linear scaling based on ODE time vector - tind = np.floor((len(time)/Narrows) * (j-k)) + k; - elif (logtimeFlag): + 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): + # 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; + # 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; + continue # Figure out the arrow at this point on the curve - x1[i,j] = state[tind, 0]; - x2[i,j] = state[tind, 1]; + 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)): - v = odefun((x1[i,j], x2[i,j]), 0, *parms) - dx[i, j, 0] = v[0]; dx[i, j, 1] = v[1]; + 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; + dx[i, j, 0] = 0; dx[i, j, 1] = 0 # Set the plot shape before plotting arrows to avoid warping - # a=gca; + # a=gca # if (scale != None): - # set(a,'DataAspectRatio', [1,1,1]); + # set(a,'DataAspectRatio', [1,1,1]) # if (xmin != xmax and ymin != ymax): - # mpl.axis([xmin, xmax, ymin, ymax]); - # set(a, 'Box', 'on'); + # 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 - mpl.quiver(x1, x2, dx[:,:,0], dx[:,:,1], angles='xy') - elif (scale != 0 and Narrows > 0): + plt.quiver(x1, x2, dx[:,:,0], dx[:,:,1], angles='xy') + elif scale != 0 and Narrows > 0: #! TODO: figure out arguments to make arrows show up correctly - xy = mpl.quiver(x1, x2, dx[:,:,0]*abs(scale), dx[:,:,1]*abs(scale), + xy = plt.quiver(x1, x2, dx[:,:,0]*abs(scale), dx[:,:,1]*abs(scale), angles='xy') - # set(xy, 'LineWidth', PP_arrow_linewidth); - # set(xy, 'AutoScale', 'off'); - # set(xy, 'AutoScaleFactor', 0); + # set(xy, 'LineWidth', PP_arrow_linewidth) + # set(xy, 'AutoScale', 'off') + # set(xy, 'AutoScaleFactor', 0) - if (scale < 0): - bp = mpl.plot(x1, x2, 'b.'); # add dots at base - # set(bp, 'MarkerSize', PP_arrow_markersize); + if scale < 0: + bp = plt.plot(x1, x2, 'b.'); # add dots at base + # set(bp, 'MarkerSize', PP_arrow_markersize) - return; # Utility function for generating initial conditions around a box def box_grid(xlimp, ylimp): @@ -298,10 +1187,22 @@ def box_grid(xlimp, ylimp): box defined by the corners [xmin ymin] and [xmax ymax]. """ - sx10 = np.linspace(xlimp[0], xlimp[1], xlimp[2]) - sy10 = np.linspace(ylimp[0], ylimp[1], ylimp[2]) + # 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] - sx1 = np.hstack((0, sx10, 0*sy10+sx10[0], sx10, 0*sy10+sx10[-1])) - sx2 = np.hstack((0, 0*sx10+sy10[0], sy10, 0*sx10+sy10[-1], sy10)) + - return np.transpose( np.vstack((sx1, sx2)) ) diff --git a/control/tests/conftest.py b/control/tests/conftest.py index 338a7088c..2330e3818 100644 --- a/control/tests/conftest.py +++ b/control/tests/conftest.py @@ -73,6 +73,16 @@ def legacy_plot_signature(): warnings.resetwarnings() +@pytest.fixture(scope="function") +def ignore_future_warning(): + """Turn off warnings for functions that generate FutureWarning""" + import warnings + warnings.filterwarnings( + 'ignore', message='.*deprecated', category=FutureWarning) + yield + warnings.resetwarnings() + + # Allow pytest.mark.slow to mark slow tests (skip with pytest -m "not slow") def pytest_configure(config): config.addinivalue_line("markers", "slow: mark test as slow to run") diff --git a/control/tests/kwargs_test.py b/control/tests/kwargs_test.py index 53cc0076b..8180ff418 100644 --- a/control/tests/kwargs_test.py +++ b/control/tests/kwargs_test.py @@ -30,7 +30,8 @@ import control.tests.descfcn_test as descfcn_test @pytest.mark.parametrize("module, prefix", [ - (control, ""), (control.flatsys, "flatsys."), (control.optimal, "optimal.") + (control, ""), (control.flatsys, "flatsys."), + (control.optimal, "optimal."), (control.phaseplot, "phaseplot.") ]) def test_kwarg_search(module, prefix): # Look through every object in the package @@ -62,7 +63,12 @@ def test_kwarg_search(module, prefix): continue # Make sure there is a unit test defined - assert prefix + name in kwarg_unittest + if prefix + name not in kwarg_unittest: + # For phaseplot module, look for tests w/out prefix (and skip) + if prefix.startswith('phaseplot.') and \ + (prefix + name)[10:] in kwarg_unittest: + continue + pytest.fail(f"couldn't find kwarg test for {prefix}{name}") # Make sure there is a unit test if not hasattr(kwarg_unittest[prefix + name], '__call__'): @@ -151,6 +157,11 @@ def test_unrecognized_kwargs(function, nsssys, ntfsys, moreargs, kwargs, (control.nichols_plot, 1, (), {}), (control.nyquist, 1, (), {}), (control.nyquist_plot, 1, (), {}), + (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.equilpoints, 1, ([-1, 1, -1, 1], ), {}), + (control.phaseplot.separatrices, 1, ([-1, 1, -1, 1], ), {}), (control.singular_values_plot, 1, (), {})] ) def test_matplotlib_kwargs(function, nsysargs, moreargs, kwargs, mplcleanup): @@ -245,8 +256,9 @@ def test_response_plot_kwargs(data_fcn, plot_fcn, mimo): 'nyquist': test_matplotlib_kwargs, 'nyquist_response': test_response_plot_kwargs, 'nyquist_plot': test_matplotlib_kwargs, - 'pzmap': test_unrecognized_kwargs, + 'phase_plane_plot': test_matplotlib_kwargs, 'pole_zero_plot': test_unrecognized_kwargs, + 'pzmap': test_unrecognized_kwargs, 'rlocus': test_unrecognized_kwargs, 'root_locus': test_unrecognized_kwargs, 'root_locus_plot': test_unrecognized_kwargs, @@ -267,10 +279,10 @@ def test_response_plot_kwargs(data_fcn, plot_fcn, mimo): flatsys_test.TestFlatSys.test_point_to_point_errors, 'flatsys.solve_flat_ocp': flatsys_test.TestFlatSys.test_solve_flat_ocp_errors, + 'flatsys.FlatSystem.__init__': test_unrecognized_kwargs, 'optimal.create_mpc_iosystem': optimal_test.test_mpc_iosystem_rename, 'optimal.solve_ocp': optimal_test.test_ocp_argument_errors, 'optimal.solve_oep': optimal_test.test_oep_argument_errors, - 'flatsys.FlatSystem.__init__': test_unrecognized_kwargs, 'FrequencyResponseData.__init__': frd_test.TestFRD.test_unrecognized_keyword, 'FrequencyResponseData.plot': test_response_plot_kwargs, @@ -305,6 +317,10 @@ def test_response_plot_kwargs(data_fcn, plot_fcn, mimo): optimal_test.test_oep_argument_errors, 'optimal.OptimalEstimationProblem.create_mhe_iosystem': optimal_test.test_oep_argument_errors, + 'phaseplot.streamlines': test_matplotlib_kwargs, + 'phaseplot.vectorfield': 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 8336ae975..a01ab2aea 100644 --- a/control/tests/phaseplot_test.py +++ b/control/tests/phaseplot_test.py @@ -10,24 +10,25 @@ """ -import matplotlib.pyplot as mpl +import matplotlib.pyplot as plt import numpy as np from numpy import pi import pytest from control import phase_plot +import control as ct +import control.phaseplot as pp - -@pytest.mark.usefixtures("mplcleanup") +# Legacy tests +@pytest.mark.usefixtures("mplcleanup", "ignore_future_warning") class TestPhasePlot: - - def testInvPendNoSims(self): - phase_plot(self.invpend_ode, (-6,6,10), (-6,6,10)); - def testInvPendSims(self): phase_plot(self.invpend_ode, (-6,6,10), (-6,6,10), X0 = ([1,1], [-1,1])) + def testInvPendNoSims(self): + phase_plot(self.invpend_ode, (-6,6,10), (-6,6,10)); + def testInvPendTimePoints(self): phase_plot(self.invpend_ode, (-6,6,10), (-6,6,10), X0 = ([1,1], [-1,1]), T=np.linspace(0,5,100)) @@ -46,11 +47,23 @@ def testInvPendAuto(self): phase_plot(self.invpend_ode, lingrid = 0, X0= [[-2.3056, 2.1], [2.3056, -2.1]], T=6, verbose=False) + def testInvPendFBS(self): + # Outer trajectories + phase_plot( + self.invpend_ode, timepts=[1, 4, 10], + X0=[[-2*pi, 1.6], [-2*pi, 0.5], [-1.8, 2.1], [-1, 2.1], + [4.2, 2.1], [5, 2.1], [2*pi, -1.6], [2*pi, -0.5], + [1.8, -2.1], [1, -2.1], [-4.2, -2.1], [-5, -2.1]], + T = np.linspace(0, 40, 800), + params=(1, 1, 0.2, 1)) + + # Separatrices + def testOscillatorParams(self): # default values m = 1 b = 1 - k = 1 + k = 1 phase_plot(self.oscillator_ode, timepts = [0.3, 1, 2, 3], X0 = [[-1,1], [-0.3,1], [0,1], [0.25,1], [0.5,1], [0.7,1], [1,1], [1.3,1], [1,-1], [0.3,-1], [0,-1], [-0.25,-1], @@ -69,14 +82,142 @@ def d1(x1x2,t): x1x2_0 = np.array([[-1.,1.], [-1.,-1.], [1.,1.], [1.,-1.], [-1.,0.],[1.,0.],[0.,-1.],[0.,1.],[0.,0.]]) - mpl.figure(1) + plt.figure(1) phase_plot(d1,X0=x1x2_0,T=100) # Sample dynamical systems - inverted pendulum - def invpend_ode(self, x, t, m=1., l=1., b=0, g=9.8): + def invpend_ode(self, x, t, m=1., l=1., b=0.2, g=1): import numpy as np return (x[1], -b/m*x[1] + (g*l/m) * np.sin(x[0])) # Sample dynamical systems - oscillator def oscillator_ode(self, x, t, m=1., b=1, k=1, extra=None): return (x[1], -k/m*x[0] - b/m*x[1]) + + +@pytest.mark.parametrize( + "func, args, kwargs", [ + [ct.phaseplot.vectorfield, [], {}], + [ct.phaseplot.vectorfield, [], + {'color': 'k', 'gridspec': [4, 3], 'params': {}}], + [ct.phaseplot.streamlines, [1], {'params': {}, 'arrows': 5}], + [ct.phaseplot.streamlines, [], + {'dir': 'forward', 'gridtype': 'meshgrid', 'color': 'k'}], + [ct.phaseplot.streamlines, [1], + {'dir': 'reverse', 'gridtype': 'boxgrid', 'color': None}], + [ct.phaseplot.streamlines, [1], + {'dir': 'both', 'gridtype': 'circlegrid', 'gridspec': [0.5, 5]}], + [ct.phaseplot.equilpoints, [], {}], + [ct.phaseplot.equilpoints, [], {'color': 'r', 'gridspec': [5, 5]}], + [ct.phaseplot.separatrices, [], {}], + [ct.phaseplot.separatrices, [], {'color': 'k', 'arrows': 4}], + [ct.phaseplot.separatrices, [5], {'params': {}, 'gridspec': [5, 5]}], + [ct.phaseplot.separatrices, [5], {'color': ('r', 'g')}], + ]) +def test_helper_functions(func, args, kwargs): + # Test with system + sys = ct.nlsys( + lambda t, x, u, params: [x[0] - 3*x[1], -3*x[0] + x[1]], + states=2, inputs=0) + out = func(sys, [-1, 1, -1, 1], *args, **kwargs) + + # Test with function + rhsfcn = lambda t, x: sys.dynamics(t, x, 0, {}) + out = func(rhsfcn, [-1, 1, -1, 1], *args, **kwargs) + + +def test_system_types(): + # Sample dynamical systems - inverted pendulum + 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])) + + # 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)}) + + # Linear I/O system + ct.phase_plane_plot( + ct.ss([[0, 1], [-1, -1]], [[0], [1]], [[1, 0]], 0)) + + +def test_phaseplane_errors(): + with pytest.raises(ValueError, match="invalid grid specification"): + ct.phase_plane_plot(ct.rss(2, 1, 1), gridspec='bad') + + with pytest.raises(ValueError, match="unknown grid type"): + ct.phase_plane_plot(ct.rss(2, 1, 1), gridtype='bad') + + with pytest.raises(ValueError, match="system must be planar"): + ct.phase_plane_plot(ct.rss(3, 1, 1)) + + 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)}) + + + + +def test_basic_phase_plots(savefigs=False): + sys = ct.nlsys( + lambda t, x, u, params: np.array([[0, 1], [-1, -1]]) @ x, + states=['position', 'velocity'], inputs=0, name='damped oscillator') + + plt.figure() + axis_limits = [-1, 1, -1, 1] + T = 8 + ct.phase_plane_plot(sys, axis_limits, T) + if savefigs: + plt.savefig('phaseplot-dampedosc-default.png') + + def invpend_update(t, x, u, params): + m, l, b, g = params['m'], params['l'], params['b'], params['g'] + return [x[1], -b/m * x[1] + (g * l / m) * np.sin(x[0]) + u[0]/m] + invpend = ct.nlsys(invpend_update, states=2, inputs=1, name='invpend') + + plt.figure() + 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}) + plt.xlabel(r"$\theta$ [rad]") + plt.ylabel(r"$\dot\theta$ [rad/sec]") + + if savefigs: + plt.savefig('phaseplot-invpend-meshgrid.png') + + def oscillator_update(t, x, u, params): + return [x[1] + x[0] * (1 - x[0]**2 - x[1]**2), + -x[0] + x[1] * (1 - x[0]**2 - x[1]**2)] + oscillator = ct.nlsys( + 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) + pp.streamlines( + oscillator, np.array([[0, 0]]), 1.5, + gridtype='circlegrid', gridspec=[0.5, 6], dir='both') + pp.streamlines(oscillator, np.array([[1, 0]]), 2*pi, arrows=6, color='b') + plt.gca().set_aspect('equal') + + if savefigs: + plt.savefig('phaseplot-oscillator-helpers.png') + + +if __name__ == "__main__": + # + # Interactive mode: generate plots for manual viewing + # + # Running this script in python (or better ipython) will show a + # collection of figures that should all look OK on the screeen. + # + + # In interactive mode, turn on ipython interactive graphics + plt.ion() + + # Start by clearing existing figures + plt.close('all') + + test_basic_phase_plots(savefigs=True) diff --git a/doc/Makefile b/doc/Makefile index 71e493f23..dfd34f4f1 100644 --- a/doc/Makefile +++ b/doc/Makefile @@ -16,7 +16,8 @@ help: # Rules to create figures FIGS = classes.pdf timeplot-mimo_step-default.png \ - freqplot-siso_bode-default.png rlocus-siso_ctime-default.png + freqplot-siso_bode-default.png rlocus-siso_ctime-default.png \ + phaseplot-dampedosc-default.png classes.pdf: classes.fig fig2dev -Lpdf $< $@ @@ -29,6 +30,9 @@ freqplot-siso_bode-default.png: ../control/tests/freqplot_test.py rlocus-siso_ctime-default.png: ../control/tests/rlocus_test.py PYTHONPATH=.. python $< +phaseplot-dampedosc-default.png: ../control/tests/phaseplot_test.py + PYTHONPATH=.. python $< + # Catch-all target: route all unknown targets to Sphinx using the new # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). html pdf clean doctest: Makefile $(FIGS) diff --git a/doc/conf.py b/doc/conf.py index 6be6d5d84..7a45ba3f9 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -282,5 +282,6 @@ def linkcode_resolve(domain, info): import control as ct import control.optimal as obc import control.flatsys as fs +import control.phaseplot as pp ct.reset_defaults() """ diff --git a/doc/examples.rst b/doc/examples.rst index 41e2b42d6..21364157e 100644 --- a/doc/examples.rst +++ b/doc/examples.rst @@ -24,7 +24,7 @@ other sources. pvtol-nested pvtol-lqr rss-balred - phaseplots + phase_plane_plots robust_siso robust_mimo scherer_etal_ex7_H2_h2syn diff --git a/doc/phase_plane_plots.py b/doc/phase_plane_plots.py new file mode 120000 index 000000000..6076fa4cd --- /dev/null +++ b/doc/phase_plane_plots.py @@ -0,0 +1 @@ +../examples/phase_plane_plots.py \ No newline at end of file diff --git a/doc/phaseplots.rst b/doc/phase_plane_plots.rst similarity index 83% rename from doc/phaseplots.rst rename to doc/phase_plane_plots.rst index 44beed598..e0068c05f 100644 --- a/doc/phaseplots.rst +++ b/doc/phase_plane_plots.rst @@ -3,7 +3,7 @@ Phase plot examples Code .... -.. literalinclude:: phaseplots.py +.. literalinclude:: phase_plane_plots.py :language: python :linenos: diff --git a/doc/phaseplot-dampedosc-default.png b/doc/phaseplot-dampedosc-default.png new file mode 100644 index 000000000..da4e24e35 Binary files /dev/null and b/doc/phaseplot-dampedosc-default.png differ diff --git a/doc/phaseplot-invpend-meshgrid.png b/doc/phaseplot-invpend-meshgrid.png new file mode 100644 index 000000000..040b45558 Binary files /dev/null and b/doc/phaseplot-invpend-meshgrid.png differ diff --git a/doc/phaseplot-oscillator-helpers.png b/doc/phaseplot-oscillator-helpers.png new file mode 100644 index 000000000..0b5ebf43f Binary files /dev/null and b/doc/phaseplot-oscillator-helpers.png differ diff --git a/doc/plotting.rst b/doc/plotting.rst index 2f6857c35..8eb548a85 100644 --- a/doc/plotting.rst +++ b/doc/plotting.rst @@ -11,6 +11,7 @@ for example:: bode_plot(sys) nyquist_plot([sys1, sys2]) + phase_plane_plot(sys, limits) pole_zero_plot(sys) root_locus_plot(sys) @@ -21,9 +22,9 @@ returns and object representing the output data. A separate plotting function, typically ending in `_plot` is then used to plot the data, resulting in the following standard pattern:: - response = nyquist_response([sys1, sys2]) - count = response.count # number of encirclements of -1 - lines = nyquist_plot(response) # Nyquist plot + response = ct.nyquist_response([sys1, sys2]) + count = ct.response.count # number of encirclements of -1 + lines = ct.nyquist_plot(response) # Nyquist plot The returned value `lines` provides access to the individual lines in the generated plot, allowing various aspects of the plot to be modified to suit @@ -35,6 +36,7 @@ analysis object, allowing the following type of calls:: step_response(sys).plot() frequency_response(sys).plot() nyquist_response(sys).plot() + pp.streamlines(sys, limits).plot() root_locus_map(sys).plot() The remainder of this chapter provides additional documentation on how @@ -58,7 +60,7 @@ response for a two-input, two-output can be plotted using the commands:: sys_mimo = ct.tf2ss( [[[1], [0.1]], [[0.2], [1]]], [[[1, 0.6, 1], [1, 1, 1]], [[1, 0.4, 1], [1, 2, 1]]], name="sys_mimo") - response = step_response(sys) + response = ct.step_response(sys) response.plot() which produces the following plot: @@ -274,6 +276,100 @@ for each system is plotted in different colors:: .. image:: rlocus-siso_multiple-nogrid.png +Phase plane plots +================= +Insight into nonlinear systems can often be obtained by looking at phase +plane diagrams. The :func:`~control.phase_plane_plot` function allows the +creation of a 2-dimensional phase plane diagram for a system. This +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:: + + sys = ct.nlsys( + lambda t, x, u, params: np.array([[0, 1], [-1, -1]]) @ x, + states=['position', 'velocity'], inputs=0, name='damped oscillator') + axis_limits = [-1, 1, -1, 1] + T = 8 + ct.phase_plane_plot(sys, axis_limits, T) + +.. image:: phaseplot-dampedosc-default.png + +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 +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. + +To illustrate some of these possibilities, consider a phase plane plot for +an inverted pendulum system, which is created using a mesh grid:: + + def invpend_update(t, x, u, params): + m, l, b, g = params['m'], params['l'], params['b'], params['g'] + return [x[1], -b/m * x[1] + (g * l / m) * np.sin(x[0]) + u[0]/m] + 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_equilpoints={'gridspec': [12, 9]}, + params={'m': 1, 'l': 1, 'b': 0.2, 'g': 1}) + plt.xlabel(r"$\theta$ [rad]") + plt.ylabel(r"$\dot\theta$ [rad/sec]") + +.. image:: phaseplot-invpend-meshgrid.png + +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 +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:`~control.phaseplot` (pp) module:: + + import control.phaseplot as pp + + def oscillator_update(t, x, u, params): + return [x[1] + x[0] * (1 - x[0]**2 - x[1]**2), + -x[0] + x[1] * (1 - x[0]**2 - x[1]**2)] + 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) + pp.streamlines( + oscillator, np.array([[0, 0]]), 1.5, + gridtype='circlegrid', gridspec=[0.5, 6], dir='both') + pp.streamlines( + oscillator, np.array([[1, 0]]), 2*pi, arrows=6, color='b') + plt.gca().set_aspect('equal') + +.. image:: phaseplot-oscillator-helpers.png + +The following helper functions are available: + +.. autosummary:: + ~control.phaseplot.equilpoints + ~control.phaseplot.separatrices + ~control.phaseplot.streamlines + ~control.phaseplot.vectorfield + +The :func:`~control.phase_plane_plot` function calls these helper functions +based on the options it is passed. + +Note that unlike other plotting functions, phase plane plots do not involve +computing a response and then plotting the result via a `plot()` method. +Instead, the plot is generated directly be a call to the +:func:`~control.phase_plane_plot` function (or one of the +:mod:`~control.phaseplot` helper functions. + + Response and plotting functions =============================== @@ -310,6 +406,11 @@ Plotting functions ~control.bode_plot ~control.describing_function_plot ~control.nichols_plot + ~control.phase_plane_plot + ~control.phaseplot.equilpoints + ~control.phaseplot.separatrices + ~control.phaseplot.streamlines + ~control.phaseplot.vectorfield ~control.pole_zero_plot ~control.root_locus_plot ~control.singular_values_plot diff --git a/examples/phase_plane_plots.py b/examples/phase_plane_plots.py new file mode 100644 index 000000000..b3b2a01c3 --- /dev/null +++ b/examples/phase_plane_plots.py @@ -0,0 +1,215 @@ +# phase_plane_plots.py - phase portrait examples +# RMM, 25 Mar 2024 +# +# This file contains a number of examples of phase plane plots generated +# using the phaseplot module. Most of these figures line up with examples +# in FBS2e, with different display options shown as different subplots. + +import time +import warnings +from math import pi, sqrt + +import matplotlib.pyplot as plt +import numpy as np + +import control as ct +import control.phaseplot as pp + +# +# Example 1: Dampled oscillator systems +# + +# Oscillator parameters +damposc_params = {'m': 1, 'b': 1, 'k': 1} + +# System model (as ODE) +def damposc_update(t, x, u, params): + m, b, k = params['m'], params['b'], params['k'] + return np.array([x[1], -k/m * x[0] - b/m * x[1]]) +damposc = ct.nlsys(damposc_update, states=2, inputs=0, params=damposc_params) + +fig, ((ax1, ax2), (ax3, ax4)) = plt.subplots(2, 2) +fig.set_tight_layout(True) +plt.suptitle("FBS Figure 5.3: damped oscillator") + +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], 4, ax=ax3, gridtype='circlegrid', dir='both') +ax3.set_title("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) +ax4.set_title("circlegrid [0, 0, 0.1], reverse") + +# +# Example 2: Inverted pendulum +# + +def invpend_update(t, x, u, params): + m, l, b, g = params['m'], params['l'], params['b'], params['g'] + return [x[1], -b/m * x[1] + (g * l / m) * np.sin(x[0])] +invpend = ct.nlsys( + invpend_update, states=2, inputs=0, + params={'m': 1, 'l': 1, 'b': 0.2, 'g': 1}) + +fig, ((ax1, ax2), (ax3, ax4)) = plt.subplots(2, 2) +fig.set_tight_layout(True) +plt.suptitle("FBS Figure 5.4: inverted pendulum") + +ct.phase_plane_plot( + invpend, [-2*pi, 2*pi, -2, 2], 5, ax=ax1) +ax1.set_title("default, 5") + +ct.phase_plane_plot( + invpend, [-2*pi, 2*pi, -2, 2], gridtype='meshgrid', ax=ax2) +ax2.set_title("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") + +ct.phase_plane_plot( + invpend, [-2*pi, 2*pi, -2, 2], 4, gridspec=[6, 6], + plot_separatrices={'timedata': 20, 'arrows': 4}, ax=ax4) +ax4.set_title("custom") + +# +# Example 3: Limit cycle (nonlinear oscillator) +# + +def oscillator_update(t, x, u, params): + return [ + x[1] + x[0] * (1 - x[0]**2 - x[1]**2), + -x[0] + x[1] * (1 - x[0]**2 - x[1]**2) + ] +oscillator = ct.nlsys(oscillator_update, states=2, inputs=0) + +fig, ((ax1, ax2), (ax3, ax4)) = plt.subplots(2, 2) +fig.set_tight_layout(True) +plt.suptitle("FBS Figure 5.5: Nonlinear oscillator") + +ct.phase_plane_plot(oscillator, [-1.5, 1.5, -1.5, 1.5], 3, ax=ax1) +ax1.set_title("default, 3") +ax1.set_aspect('equal') + +try: + ct.phase_plane_plot( + oscillator, [-1.5, 1.5, -1.5, 1.5], 1, gridtype='meshgrid', + dir='forward', ax=ax2) +except RuntimeError as inst: + axs[0,1].text(0, 0, "Runtime Error") + warnings.warn(inst.__str__()) +ax2.set_title("meshgrid, forward, 0.5") +ax2.set_aspect('equal') + +ct.phase_plane_plot(oscillator, [-1.5, 1.5, -1.5, 1.5], ax=ax3) +pp.streamlines( + oscillator, [-0.5, 0.5, -0.5, 0.5], dir='both', ax=ax3) +ax3.set_title("outer + inner") +ax3.set_aspect('equal') + +ct.phase_plane_plot( + oscillator, [-1.5, 1.5, -1.5, 1.5], 0.9, ax=ax4) +pp.streamlines( + oscillator, np.array([[0, 0]]), 1.5, + gridtype='circlegrid', gridspec=[0.5, 6], dir='both', ax=ax4) +pp.streamlines( + oscillator, np.array([[1, 0]]), 2*pi, arrows=6, ax=ax4, color='b') +ax4.set_title("custom") +ax4.set_aspect('equal') + +# +# Example 4: Simple saddle +# + +def saddle_update(t, x, u, params): + return [x[0] - 3*x[1], -3*x[0] + x[1]] +saddle = ct.nlsys(saddle_update, states=2, inputs=0) + +fig, ((ax1, ax2), (ax3, ax4)) = plt.subplots(2, 2) +fig.set_tight_layout(True) +plt.suptitle("FBS Figure 5.9: Saddle") + +ct.phase_plane_plot(saddle, [-1, 1, -1, 1], ax=ax1) +ax1.set_title("default") + +ct.phase_plane_plot( + saddle, [-1, 1, -1, 1], 0.5, gridtype='meshgrid', ax=ax2) +ax2.set_title("meshgrid") + +ct.phase_plane_plot( + saddle, [-1, 1, -1, 1], gridspec=[16, 12], ax=ax3, + plot_vectorfield=True, plot_streamlines=False, plot_separatrices=False) +ax3.set_title("vectorfield") + +ct.phase_plane_plot( + saddle, [-1, 1, -1, 1], 0.3, + gridtype='meshgrid', gridspec=[5, 7], ax=ax4) +ax3.set_title("custom") + +# +# Example 5: Internet congestion control +# + +def _congctrl_update(t, x, u, params): + # Number of sources per state of the simulation + M = x.size - 1 # general case + assert M == 1 # make sure nothing funny happens here + + # Remaining parameters + N = params.get('N', M) # number of sources + rho = params.get('rho', 2e-4) # RED parameter = pbar / (bupper-blower) + c = params.get('c', 10) # link capacity (Mp/ms) + + # Compute the derivative (last state = bdot) + 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}) + +fig, ((ax1, ax2), (ax3, ax4)) = plt.subplots(2, 2) +fig.set_tight_layout(True) +plt.suptitle("FBS Figure 5.10: Congestion control") + +try: + ct.phase_plane_plot( + congctrl, [0, 10, 100, 500], 120, ax=ax1) +except RuntimeError as inst: + ax1.text(5, 250, "Runtime Error") + warnings.warn(inst.__str__()) +ax1.set_title("default, T=120") + +try: + ct.phase_plane_plot( + congctrl, [0, 10, 100, 500], 120, + params={'rho': 4e-4, 'c': 20}, ax=ax2) +except RuntimeError as inst: + ax2.text(5, 250, "Runtime Error") + warnings.warn(inst.__str__()) +ax2.set_title("updated param") + +ct.phase_plane_plot( + congctrl, [0, 10, 100, 500], ax=ax3, + plot_vectorfield=True, plot_streamlines=False) +ax3.set_title("vector field") + +ct.phase_plane_plot( + congctrl, [2, 6, 200, 300], 100, + params={'rho': 4e-4, 'c': 20}, + ax=ax4, plot_vectorfield={'gridspec': [12, 9]}) +ax4.set_title("vector field + streamlines") + +# +# End of examples +# + +plt.show(block=False) diff --git a/examples/phaseplots.py b/examples/phaseplots.py deleted file mode 100644 index cf05c384a..000000000 --- a/examples/phaseplots.py +++ /dev/null @@ -1,166 +0,0 @@ -# phaseplots.py - examples of phase portraits -# RMM, 24 July 2011 -# -# This file contains examples of phase portraits pulled from "Feedback -# Systems" by Astrom and Murray (Princeton University Press, 2008). - -import os - -import numpy as np -import matplotlib.pyplot as plt -from control.phaseplot import phase_plot -from numpy import pi - -# Clear out any figures that are present -plt.close('all') - -# -# Inverted pendulum -# - -# Define the ODEs for a damped (inverted) pendulum -def invpend_ode(x, t, m=1., l=1., b=0.2, g=1): - return x[1], -b/m*x[1] + (g*l/m)*np.sin(x[0]) - - -# Set up the figure the way we want it to look -plt.figure() -plt.clf() -plt.axis([-2*pi, 2*pi, -2.1, 2.1]) -plt.title('Inverted pendulum') - -# Outer trajectories -phase_plot( - invpend_ode, - X0=[[-2*pi, 1.6], [-2*pi, 0.5], [-1.8, 2.1], - [-1, 2.1], [4.2, 2.1], [5, 2.1], - [2*pi, -1.6], [2*pi, -0.5], [1.8, -2.1], - [1, -2.1], [-4.2, -2.1], [-5, -2.1]], - T=np.linspace(0, 40, 200), - logtime=(3, 0.7) -) - -# Separatrices -phase_plot(invpend_ode, X0=[[-2.3056, 2.1], [2.3056, -2.1]], T=6, lingrid=0) - -# -# Systems of ODEs: damped oscillator example (simulation + phase portrait) -# - -def oscillator_ode(x, t, m=1., b=1, k=1): - return x[1], -k/m*x[0] - b/m*x[1] - - -# Generate a vector plot for the damped oscillator -plt.figure() -plt.clf() -phase_plot(oscillator_ode, [-1, 1, 10], [-1, 1, 10], 0.15) -#plt.plot([0], [0], '.') -# a=gca; set(a,'FontSize',20); set(a,'DataAspectRatio',[1,1,1]) -plt.xlabel('$x_1$') -plt.ylabel('$x_2$') -plt.title('Damped oscillator, vector field') - -# Generate a phase plot for the damped oscillator -plt.figure() -plt.clf() -plt.axis([-1, 1, -1, 1]) # set(gca, 'DataAspectRatio', [1, 1, 1]); -phase_plot( - oscillator_ode, - X0=[ - [-1, 1], [-0.3, 1], [0, 1], [0.25, 1], [0.5, 1], [0.75, 1], [1, 1], - [1, -1], [0.3, -1], [0, -1], [-0.25, -1], [-0.5, -1], [-0.75, -1], [-1, -1] - ], - T=np.linspace(0, 8, 80), - timepts=[0.25, 0.8, 2, 3] -) -plt.plot([0], [0], 'k.') # 'MarkerSize', AM_data_markersize*3) -# set(gca, 'DataAspectRatio', [1,1,1]) -plt.xlabel('$x_1$') -plt.ylabel('$x_2$') -plt.title('Damped oscillator, vector field and stream lines') - -# -# Stability definitions -# -# This set of plots illustrates the various types of equilibrium points. -# - - -def saddle_ode(x, t): - """Saddle point vector field""" - return x[0] - 3*x[1], -3*x[0] + x[1] - - -# Asy stable -m = 1 -b = 1 -k = 1 # default values -plt.figure() -plt.clf() -plt.axis([-1, 1, -1, 1]) # set(gca, 'DataAspectRatio', [1 1 1]); -phase_plot( - oscillator_ode, - X0=[ - [-1, 1], [-0.3, 1], [0, 1], [0.25, 1], [0.5, 1], [0.7, 1], [1, 1], [1.3, 1], - [1, -1], [0.3, -1], [0, -1], [-0.25, -1], [-0.5, -1], [-0.7, -1], [-1, -1], - [-1.3, -1] - ], - T=np.linspace(0, 10, 100), - timepts=[0.3, 1, 2, 3], - parms=(m, b, k) -) -plt.plot([0], [0], 'k.') # 'MarkerSize', AM_data_markersize*3) -# plt.set(gca,'FontSize', 16) -plt.xlabel('$x_1$') -plt.ylabel('$x_2$') -plt.title('Asymptotically stable point') - -# Saddle -plt.figure() -plt.clf() -plt.axis([-1, 1, -1, 1]) # set(gca, 'DataAspectRatio', [1 1 1]) -phase_plot( - saddle_ode, - scale=2, - timepts=[0.2, 0.5, 0.8], - X0=[ - [-1, -1], [1, 1], - [-1, -0.95], [-1, -0.9], [-1, -0.8], [-1, -0.6], [-1, -0.4], [-1, -0.2], - [-0.95, -1], [-0.9, -1], [-0.8, -1], [-0.6, -1], [-0.4, -1], [-0.2, -1], - [1, 0.95], [1, 0.9], [1, 0.8], [1, 0.6], [1, 0.4], [1, 0.2], - [0.95, 1], [0.9, 1], [0.8, 1], [0.6, 1], [0.4, 1], [0.2, 1], - [-0.5, -0.45], [-0.45, -0.5], [0.5, 0.45], [0.45, 0.5], - [-0.04, 0.04], [0.04, -0.04] - ], - T=np.linspace(0, 2, 20) -) -plt.plot([0], [0], 'k.') # 'MarkerSize', AM_data_markersize*3) -# set(gca,'FontSize', 16) -plt.xlabel('$x_1$') -plt.ylabel('$x_2$') -plt.title('Saddle point') - -# Stable isL -m = 1 -b = 0 -k = 1 # zero damping -plt.figure() -plt.clf() -plt.axis([-1, 1, -1, 1]) # set(gca, 'DataAspectRatio', [1 1 1]); -phase_plot( - oscillator_ode, - timepts=[pi/6, pi/3, pi/2, 2*pi/3, 5*pi/6, pi, 7*pi/6, - 4*pi/3, 9*pi/6, 5*pi/3, 11*pi/6, 2*pi], - X0=[[0.2, 0], [0.4, 0], [0.6, 0], [0.8, 0], [1, 0], [1.2, 0], [1.4, 0]], - T=np.linspace(0, 20, 200), - parms=(m, b, k) -) -plt.plot([0], [0], 'k.') # 'MarkerSize', AM_data_markersize*3) -# plt.set(gca,'FontSize', 16) -plt.xlabel('$x_1$') -plt.ylabel('$x_2$') -plt.title('Undamped system\nLyapunov stable, not asympt. stable') - -if 'PYCONTROL_TEST_EXAMPLES' not in os.environ: - plt.show()