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() 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