• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

python-control / python-control / 31297641779

09 Aug 2026 05:52AM UTC coverage: 94.74% (+0.2%) from 94.579%
31297641779

push

github

web-flow
Merge pull request #1243 from murrayrm

Fix CI test and doctest failures with updated NumPy, SciPy, and Matplotlib
https://github.com/python-control/python-control/pull/1243

9961 of 10514 relevant lines covered (94.74%)

8.28 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

93.39
control/phaseplot.py
1
# phaseplot.py - generate 2D phase portraits
2
#
3
# Initial author: Richard M. Murray
4
# Creation date: 24 July 2011, converted from MATLAB version (2002);
5
# based on an original version by Kristi Morgansen
6

7
"""Generate 2D phase portraits.
8

9
This module contains functions for generating 2D phase plots. The base
10
function for creating phase plane portraits is `~control.phase_plane_plot`,
11
which generates a phase plane portrait for a 2 state I/O system (with no
12
inputs). Utility functions are available to customize the individual
13
elements of a phase plane portrait.
14

15
The docstring examples assume the following import commands::
16

17
  >>> import numpy as np
18
  >>> import control as ct
19
  >>> import control.phaseplot as pp
20

21
"""
22

23
import math
9✔
24
import warnings
9✔
25

26
import matplotlib as mpl
9✔
27
import matplotlib.pyplot as plt
9✔
28
import numpy as np
9✔
29
from scipy.integrate import odeint
9✔
30

31
from . import config
9✔
32
from .ctrlplot import ControlPlot, _add_arrows_to_line2D, _get_color, \
9✔
33
    _process_ax_keyword, _update_plot_title
34
from .exception import ControlArgument
9✔
35
from .nlsys import NonlinearIOSystem, find_operating_point, \
9✔
36
    input_output_response
37

38
__all__ = ['phase_plane_plot', 'phase_plot', 'box_grid']
9✔
39

40
# Default values for module parameter variables
41
_phaseplot_defaults = {
9✔
42
    'phaseplot.arrows': 2,                  # number of arrows around curve
43
    'phaseplot.arrow_size': 8,              # pixel size for arrows
44
    'phaseplot.arrow_style': None,          # set arrow style
45
    'phaseplot.separatrices_radius': 0.1    # initial radius for separatrices
46
}
47

48

49
def phase_plane_plot(
9✔
50
        sys, pointdata=None, timedata=None, gridtype=None, gridspec=None,
51
        plot_streamlines=None, plot_vectorfield=None, plot_streamplot=None,
52
        plot_equilpoints=True, plot_separatrices=True, ax=None,
53
        suppress_warnings=False, title=None, **kwargs
54
):
55
    """Plot phase plane diagram.
56

57
    This function plots phase plane data, including vector fields, stream
58
    lines, equilibrium points, and contour curves.
59
    If none of plot_streamlines, plot_vectorfield, or plot_streamplot are
60
    set, then plot_streamplot is used by default.
61

62
    Parameters
63
    ----------
64
    sys : `NonlinearIOSystem` or callable(t, x, ...)
65
        I/O system or function used to generate phase plane data. If a
66
        function is given, the remaining arguments are drawn from the
67
        `params` keyword.
68
    pointdata : list or 2D array
69
        List of the form [xmin, xmax, ymin, ymax] describing the
70
        boundaries of the phase plot or an array of shape (N, 2)
71
        giving points of at which to plot the vector field.
72
    timedata : int, 1D array, or 2D array
73
        Time to simulate each streamline. If a 1D array is given, then the
74
        times at which to sample the simulation for all streamlines.
75
        If a 2D array is given, then for each row the times at which
76
        to sample the simulation for the corresponding streamline.
77
    gridtype : str, optional
78
        The type of grid to use for generating initial conditions:
79
        'meshgrid' (default) generates a mesh of initial conditions within
80
        the specified boundaries, 'boxgrid' generates initial conditions
81
        along the edges of the boundary, 'circlegrid' generates a circle of
82
        initial conditions around each point in point data.
83
    gridspec : list, optional
84
        If the gridtype is 'meshgrid' and 'boxgrid', `gridspec` gives the
85
        size of the grid in the x and y axes on which to generate points.
86
        If gridtype is 'circlegrid', then `gridspec` is a 2-tuple
87
        specifying the radius and number of points around each point in the
88
        `pointdata` array.
89
    params : dict, optional
90
        Parameters to pass to system. For an I/O system, `params` should be
91
        a dict of parameters and values. For a callable, `params` should be
92
        dict with key 'args' and value given by a tuple (passed to callable).
93
    color : matplotlib color spec, optional
94
        Plot all elements in the given color (use ``plot_<element>`` =
95
        {'color': c} to set the color in one element of the phase
96
        plot (equilpoints, separatrices, streamlines, etc).
97
    ax : `matplotlib.axes.Axes`, optional
98
        The matplotlib axes to draw the figure on.  If not specified and
99
        the current figure has a single axes, that axes is used.
100
        Otherwise, a new figure is created.
101

102
    Returns
103
    -------
104
    cplt : `ControlPlot` object
105
        Object containing the data that were plotted.  See `ControlPlot`
106
        for more detailed information.
107
    cplt.lines : array of list of `matplotlib.lines.Line2D`
108
        Array of list of `matplotlib.artist.Artist` objects:
109

110
            - lines[0] = list of Line2D objects (streamlines, separatrices).
111
            - lines[1] = Quiver object (vector field arrows).
112
            - lines[2] = list of Line2D objects (equilibrium points).
113
            - lines[3] = StreamplotSet object (lines with arrows).
114

115
    cplt.axes : 2D array of `matplotlib.axes.Axes`
116
        Axes for each subplot.
117
    cplt.figure : `matplotlib.figure.Figure`
118
        Figure containing the plot.
119

120
    Other Parameters
121
    ----------------
122
    arrows : int
123
        Set the number of arrows to plot along the streamlines. The default
124
        value can be set in `config.defaults['phaseplot.arrows']`.
125
    arrow_size : float
126
        Set the size of arrows to plot along the streamlines.  The default
127
        value can be set in `config.defaults['phaseplot.arrow_size']`.
128
    arrow_style : matplotlib patch
129
        Set the style of arrows to plot along the streamlines.  The default
130
        value can be set in `config.defaults['phaseplot.arrow_style']`.
131
    dir : str, optional
132
        Direction to draw streamlines: 'forward' to flow forward in time
133
        from the reference points, 'reverse' to flow backward in time, or
134
        'both' to flow both forward and backward.  The amount of time to
135
        simulate in each direction is given by the `timedata` argument.
136
    plot_streamlines : bool or dict, optional
137
        If True then plot streamlines based on the pointdata and gridtype.
138
        If set to a dict, pass on the key-value pairs in the dict as
139
        keywords to `streamlines`.
140
    plot_vectorfield : bool or dict, optional
141
        If True then plot the vector field based on the pointdata and
142
        gridtype.  If set to a dict, pass on the key-value pairs in the
143
        dict as keywords to `phaseplot.vectorfield`.
144
    plot_streamplot : bool or dict, optional
145
        If True then use `matplotlib.axes.Axes.streamplot` function
146
        to plot the streamlines.  If set to a dict, pass on the key-value
147
        pairs in the dict as keywords to `phaseplot.streamplot`.
148
    plot_equilpoints : bool or dict, optional
149
        If True (default) then plot equilibrium points based in the phase
150
        plot boundary. If set to a dict, pass on the key-value pairs in the
151
        dict as keywords to `phaseplot.equilpoints`.
152
    plot_separatrices : bool or dict, optional
153
        If True (default) then plot separatrices starting from each
154
        equilibrium point.  If set to a dict, pass on the key-value pairs
155
        in the dict as keywords to `phaseplot.separatrices`.
156
    rcParams : dict
157
        Override the default parameters used for generating plots.
158
        Default is set by `config.defaults['ctrlplot.rcParams']`.
159
    suppress_warnings : bool, optional
160
        If set to True, suppress warning messages in generating trajectories.
161
    title : str, optional
162
        Set the title of the plot.  Defaults to plot type and system name(s).
163

164
    Notes
165
    -----
166
    The default method for producing streamlines is determined based on which
167
    keywords are specified, with `plot_streamplot` serving as the generic
168
    default.  If any of the `arrows`, `arrow_size`, `arrow_style`, or `dir`
169
    keywords are used and neither `plot_streamlines` nor `plot_streamplot` is
170
    set, then `plot_streamlines` will be set to True.  If neither
171
    `plot_streamlines` nor `plot_vectorfield` set set to True, then
172
    `plot_streamplot` will be set to True.
173

174
    """
175
    # Check for legacy usage of plot_streamlines
176
    streamline_keywords = [
9✔
177
        'arrows', 'arrow_size', 'arrow_style', 'dir']
178
    if plot_streamlines is None:
9✔
179
        if any([kw in kwargs for kw in streamline_keywords]):
9✔
180
            warnings.warn(
×
181
                "detected streamline keywords; use plot_streamlines to set",
182
                FutureWarning)
183
            plot_streamlines = True
×
184
        if gridtype not in [None, 'meshgrid']:
9✔
185
            warnings.warn(
×
186
                "streamplots only support gridtype='meshgrid'; "
187
                "falling back to streamlines")
188
            plot_streamlines = True
×
189

190
    if plot_streamlines is None and plot_vectorfield is None \
9✔
191
       and plot_streamplot is None:
192
        plot_streamplot = True
9✔
193

194
    if plot_streamplot and not plot_streamlines and not plot_vectorfield:
9✔
195
        gridspec = gridspec or [25, 25]
9✔
196

197
    # Process arguments
198
    params = kwargs.get('params', None)
9✔
199
    sys = _create_system(sys, params)
9✔
200
    pointdata = [-1, 1, -1, 1] if pointdata is None else pointdata
9✔
201
    rcParams = config._get_param('ctrlplot', 'rcParams', kwargs, pop=True)
9✔
202

203
    # Create axis if needed
204
    user_ax = ax
9✔
205
    fig, ax = _process_ax_keyword(user_ax, squeeze=True, rcParams=rcParams)
9✔
206

207
    # Create copy of kwargs for later checking to find unused arguments
208
    initial_kwargs = dict(kwargs)
9✔
209

210
    # Utility function to create keyword arguments
211
    def _create_kwargs(global_kwargs, local_kwargs, **other_kwargs):
9✔
212
        new_kwargs = dict(global_kwargs)
9✔
213
        new_kwargs.update(other_kwargs)
9✔
214
        if isinstance(local_kwargs, dict):
9✔
215
            new_kwargs.update(local_kwargs)
9✔
216
        return new_kwargs
9✔
217

218
    # Create list for storing outputs
219
    out = np.array([[], None, None, None], dtype=object)
9✔
220

221
    # the maximum zorder of stramlines, vectorfield or streamplot
222
    flow_zorder = None
9✔
223

224
    # Plot out the main elements
225
    if plot_streamlines:
9✔
226
        kwargs_local = _create_kwargs(
9✔
227
            kwargs, plot_streamlines, gridspec=gridspec, gridtype=gridtype,
228
            ax=ax)
229
        out[0] += streamlines(
9✔
230
            sys, pointdata, timedata, _check_kwargs=False,
231
            suppress_warnings=suppress_warnings, **kwargs_local)
232

233
        new_zorder = max(elem.get_zorder() for elem in out[0])
9✔
234
        flow_zorder = max(flow_zorder, new_zorder) if flow_zorder \
9✔
235
            else new_zorder
236

237
        # Get rid of keyword arguments handled by streamlines
238
        for kw in ['arrows', 'arrow_size', 'arrow_style', 'color',
9✔
239
                   'dir', 'params']:
240
            initial_kwargs.pop(kw, None)
9✔
241

242
    # Reset the gridspec for the remaining commands, if needed
243
    if gridtype not in [None, 'boxgrid', 'meshgrid']:
9✔
244
        gridspec = None
×
245

246
    if plot_vectorfield:
9✔
247
        kwargs_local = _create_kwargs(
9✔
248
            kwargs, plot_vectorfield, gridspec=gridspec, ax=ax)
249
        out[1] = vectorfield(
9✔
250
            sys, pointdata, _check_kwargs=False, **kwargs_local)
251

252
        new_zorder = out[1].get_zorder()
9✔
253
        flow_zorder = max(flow_zorder, new_zorder) if flow_zorder \
9✔
254
            else new_zorder
255

256
        # Get rid of keyword arguments handled by vectorfield
257
        for kw in ['color', 'params']:
9✔
258
            initial_kwargs.pop(kw, None)
9✔
259

260
    if plot_streamplot:
9✔
261
        if gridtype not in [None, 'meshgrid']:
9✔
262
            raise ValueError(
9✔
263
                "gridtype must be 'meshgrid' when using streamplot")
264

265
        kwargs_local = _create_kwargs(
9✔
266
            kwargs, plot_streamplot, gridspec=gridspec, ax=ax)
267
        out[3] = streamplot(
9✔
268
            sys, pointdata, _check_kwargs=False, **kwargs_local)
269

270
        new_zorder = max(out[3].lines.get_zorder(), out[3].arrows.get_zorder())
9✔
271
        flow_zorder = max(flow_zorder, new_zorder) if flow_zorder \
9✔
272
            else new_zorder
273

274
        # Get rid of keyword arguments handled by streamplot
275
        for kw in ['color', 'params']:
9✔
276
            initial_kwargs.pop(kw, None)
9✔
277

278
    sep_zorder = flow_zorder + 1 if flow_zorder else None
9✔
279

280
    if plot_separatrices:
9✔
281
        kwargs_local = _create_kwargs(
9✔
282
            kwargs, plot_separatrices, gridspec=gridspec, ax=ax)
283
        kwargs_local['zorder'] = kwargs_local.get('zorder', sep_zorder)
9✔
284
        out[0] += separatrices(
9✔
285
            sys, pointdata, _check_kwargs=False,  **kwargs_local)
286

287
        sep_zorder = max(elem.get_zorder() for elem in out[0]) if out[0] \
9✔
288
            else None
289

290
        # Get rid of keyword arguments handled by separatrices
291
        for kw in ['arrows', 'arrow_size', 'arrow_style', 'params']:
9✔
292
            initial_kwargs.pop(kw, None)
9✔
293

294
    equil_zorder = sep_zorder + 1 if sep_zorder else None
9✔
295

296
    if plot_equilpoints:
9✔
297
        kwargs_local = _create_kwargs(
9✔
298
            kwargs, plot_equilpoints, gridspec=gridspec, ax=ax)
299
        kwargs_local['zorder'] = kwargs_local.get('zorder', equil_zorder)
9✔
300
        out[2] = equilpoints(
9✔
301
            sys, pointdata, _check_kwargs=False, **kwargs_local)
302

303
        # Get rid of keyword arguments handled by equilpoints
304
        for kw in ['params']:
9✔
305
            initial_kwargs.pop(kw, None)
9✔
306

307
    # Make sure all keyword arguments were used
308
    if initial_kwargs:
9✔
309
        raise TypeError("unrecognized keywords: ", str(initial_kwargs))
9✔
310

311
    if user_ax is None:
9✔
312
        if title is None:
9✔
313
            title = f"Phase portrait for {sys.name}"
9✔
314
        _update_plot_title(title, use_existing=False, rcParams=rcParams)
9✔
315
        ax.set_xlabel(sys.state_labels[0])
9✔
316
        ax.set_ylabel(sys.state_labels[1])
9✔
317
        plt.tight_layout()
9✔
318

319
    return ControlPlot(out, ax, fig)
9✔
320

321

322
def vectorfield(
9✔
323
        sys, pointdata, gridspec=None, zorder=None, ax=None,
324
        suppress_warnings=False, _check_kwargs=True, **kwargs):
325
    """Plot a vector field in the phase plane.
326

327
    This function plots a vector field for a two-dimensional state
328
    space system.
329

330
    Parameters
331
    ----------
332
    sys : `NonlinearIOSystem` or callable(t, x, ...)
333
        I/O system or function used to generate phase plane data.  If a
334
        function is given, the remaining arguments are drawn from the
335
        `params` keyword.
336
    pointdata : list or 2D array
337
        List of the form [xmin, xmax, ymin, ymax] describing the
338
        boundaries of the phase plot or an array of shape (N, 2)
339
        giving points of at which to plot the vector field.
340
    gridtype : str, optional
341
        The type of grid to use for generating initial conditions:
342
        'meshgrid' (default) generates a mesh of initial conditions within
343
        the specified boundaries, 'boxgrid' generates initial conditions
344
        along the edges of the boundary, 'circlegrid' generates a circle of
345
        initial conditions around each point in point data.
346
    gridspec : list, optional
347
        If the gridtype is 'meshgrid' and 'boxgrid', `gridspec` gives the
348
        size of the grid in the x and y axes on which to generate points.
349
        If gridtype is 'circlegrid', then `gridspec` is a 2-tuple
350
        specifying the radius and number of points around each point in the
351
        `pointdata` array.
352
    params : dict or list, optional
353
        Parameters to pass to system. For an I/O system, `params` should be
354
        a dict of parameters and values. For a callable, `params` should be
355
        dict with key 'args' and value given by a tuple (passed to callable).
356
    color : matplotlib color spec, optional
357
        Plot the vector field in the given color.
358
    ax : `matplotlib.axes.Axes`, optional
359
        Use the given axes for the plot, otherwise use the current axes.
360

361
    Returns
362
    -------
363
    out : Quiver
364

365
    Other Parameters
366
    ----------------
367
    rcParams : dict
368
        Override the default parameters used for generating plots.
369
        Default is set by `config.defaults['ctrlplot.rcParams']`.
370
    suppress_warnings : bool, optional
371
        If set to True, suppress warning messages in generating trajectories.
372
    zorder : float, optional
373
        Set the zorder for the vectorfield.  In not specified, it will be
374
        automatically chosen by `matplotlib.axes.Axes.quiver`.
375

376
    """
377
    # Process keywords
378
    rcParams = config._get_param('ctrlplot', 'rcParams', kwargs, pop=True)
9✔
379

380
    # Get system parameters
381
    params = kwargs.pop('params', None)
9✔
382

383
    # Create system from callable, if needed
384
    sys = _create_system(sys, params)
9✔
385

386
    # Determine the points on which to generate the vector field
387
    points, _ = _make_points(pointdata, gridspec, 'meshgrid')
9✔
388

389
    # Create axis if needed
390
    if ax is None:
9✔
391
        ax = plt.gca()
9✔
392

393
    # Set the plotting limits
394
    xlim, ylim, maxlim = _set_axis_limits(ax, pointdata)
9✔
395

396
    # Figure out the color to use
397
    color = _get_color(kwargs, ax=ax)
9✔
398

399
    # Make sure all keyword arguments were processed
400
    if _check_kwargs and kwargs:
9✔
401
        raise TypeError("unrecognized keywords: ", str(kwargs))
9✔
402

403
    # Generate phase plane (quiver) data
404
    vfdata = np.zeros((points.shape[0], 4))
9✔
405
    sys._update_params(params)
9✔
406
    for i, x in enumerate(points):
9✔
407
        vfdata[i, :2] = x
9✔
408
        vfdata[i, 2:] = sys._rhs(0, x, np.zeros(sys.ninputs))
9✔
409

410
    with plt.rc_context(rcParams):
9✔
411
        out = ax.quiver(
9✔
412
            vfdata[:, 0], vfdata[:, 1], vfdata[:, 2], vfdata[:, 3],
413
            angles='xy', color=color, zorder=zorder)
414

415
    return out
9✔
416

417

418
def streamplot(
9✔
419
        sys, pointdata, gridspec=None, zorder=None, ax=None, vary_color=False,
420
        vary_linewidth=False, cmap=None, norm=None, suppress_warnings=False,
421
        _check_kwargs=True, **kwargs):
422
    """Plot streamlines in the phase plane.
423

424
    This function plots the streamlines for a two-dimensional state
425
    space system using the `matplotlib.axes.Axes.streamplot` function.
426

427
    Parameters
428
    ----------
429
    sys : `NonlinearIOSystem` or callable(t, x, ...)
430
        I/O system or function used to generate phase plane data.  If a
431
        function is given, the remaining arguments are drawn from the
432
        `params` keyword.
433
    pointdata : list or 2D array
434
        List of the form [xmin, xmax, ymin, ymax] describing the
435
        boundaries of the phase plot.
436
    gridspec : list, optional
437
        Specifies the size of the grid in the x and y axes on which to
438
        generate points.
439
    params : dict or list, optional
440
        Parameters to pass to system. For an I/O system, `params` should be
441
        a dict of parameters and values. For a callable, `params` should be
442
        dict with key 'args' and value given by a tuple (passed to callable).
443
    color : matplotlib color spec, optional
444
        Plot the vector field in the given color.
445
    ax : `matplotlib.axes.Axes`, optional
446
        Use the given axes for the plot, otherwise use the current axes.
447

448
    Returns
449
    -------
450
    out : StreamplotSet
451
        Containter object with lines and arrows contained in the
452
        streamplot. See `matplotlib.axes.Axes.streamplot` for details.
453

454
    Other Parameters
455
    ----------------
456
    cmap : str or Colormap, optional
457
        Colormap to use for varying the color of the streamlines.
458
    norm : `matplotlib.colors.Normalize`, optional
459
        Normalization map to use for scaling the colormap and linewidths.
460
    rcParams : dict
461
        Override the default parameters used for generating plots.
462
        Default is set by `config.default['ctrlplot.rcParams']`.
463
    suppress_warnings : bool, optional
464
        If set to True, suppress warning messages in generating trajectories.
465
    vary_color : bool, optional
466
        If set to True, vary the color of the streamlines based on the
467
        magnitude of the vector field.
468
    vary_linewidth : bool, optional.
469
        If set to True, vary the linewidth of the streamlines based on the
470
        magnitude of the vector field.
471
    zorder : float, optional
472
        Set the zorder for the streamlines.  In not specified, it will be
473
        automatically chosen by `matplotlib.axes.Axes.streamplot`.
474

475
    """
476
    # Process keywords
477
    rcParams = config._get_param('ctrlplot', 'rcParams', kwargs, pop=True)
9✔
478

479
    # Get system parameters
480
    params = kwargs.pop('params', None)
9✔
481

482
    # Create system from callable, if needed
483
    sys = _create_system(sys, params)
9✔
484

485
    # Determine the points on which to generate the streamplot field
486
    points, gridspec = _make_points(pointdata, gridspec, 'meshgrid')
9✔
487
    grid_arr_shape = gridspec[::-1]
9✔
488
    xs = points[:, 0].reshape(grid_arr_shape)
9✔
489
    ys = points[:, 1].reshape(grid_arr_shape)
9✔
490

491
    # Create axis if needed
492
    if ax is None:
9✔
493
        ax = plt.gca()
9✔
494

495
    # Set the plotting limits
496
    xlim, ylim, maxlim = _set_axis_limits(ax, pointdata)
9✔
497

498
    # Figure out the color to use
499
    color = _get_color(kwargs, ax=ax)
9✔
500

501
    # Make sure all keyword arguments were processed
502
    if _check_kwargs and kwargs:
9✔
503
        raise TypeError("unrecognized keywords: ", str(kwargs))
9✔
504

505
    # Generate phase plane (quiver) data
506
    sys._update_params(params)
9✔
507
    us_flat, vs_flat = np.transpose(
9✔
508
        [sys._rhs(0, x, np.zeros(sys.ninputs)) for x in points])
509
    us, vs = us_flat.reshape(grid_arr_shape), vs_flat.reshape(grid_arr_shape)
9✔
510

511
    magnitudes = np.linalg.norm([us, vs], axis=0)
9✔
512
    norm = norm or mpl.colors.Normalize()
9✔
513
    normalized = norm(magnitudes)
9✔
514
    cmap = plt.get_cmap(cmap)
9✔
515

516
    with plt.rc_context(rcParams):
9✔
517
        default_lw = plt.rcParams['lines.linewidth']
9✔
518
        min_lw, max_lw = 0.25*default_lw, 2*default_lw
9✔
519
        linewidths = normalized * (max_lw - min_lw) + min_lw \
9✔
520
            if vary_linewidth else None
521
        color = magnitudes if vary_color else color
9✔
522

523
        out = ax.streamplot(
9✔
524
            xs, ys, us, vs, color=color, linewidth=linewidths, cmap=cmap,
525
            norm=norm, zorder=zorder)
526

527
    return out
9✔
528

529

530
def streamlines(
9✔
531
        sys, pointdata, timedata=1, gridspec=None, gridtype=None, dir=None,
532
        zorder=None, ax=None, _check_kwargs=True, suppress_warnings=False,
533
        **kwargs):
534
    """Plot stream lines in the phase plane.
535

536
    This function plots stream lines for a two-dimensional state space
537
    system.
538

539
    Parameters
540
    ----------
541
    sys : `NonlinearIOSystem` or callable(t, x, ...)
542
        I/O system or function used to generate phase plane data.  If a
543
        function is given, the remaining arguments are drawn from the
544
        `params` keyword.
545
    pointdata : list or 2D array
546
        List of the form [xmin, xmax, ymin, ymax] describing the
547
        boundaries of the phase plot or an array of shape (N, 2)
548
        giving points of at which to plot the vector field.
549
    timedata : int, 1D array, or 2D array
550
        Time to simulate each streamline. If a 1D array is given, then the
551
        times at which to sample the simulation for all streamlines.
552
        If a 2D array is given, then for each row the times at which
553
        to sample the simulation for the corresponding streamline.
554
    gridtype : str, optional
555
        The type of grid to use for generating initial conditions:
556
        'meshgrid' (default) generates a mesh of initial conditions within
557
        the specified boundaries, 'boxgrid' generates initial conditions
558
        along the edges of the boundary, 'circlegrid' generates a circle of
559
        initial conditions around each point in point data.
560
    gridspec : list, optional
561
        If the gridtype is 'meshgrid' and 'boxgrid', `gridspec` gives the
562
        size of the grid in the x and y axes on which to generate points.
563
        If gridtype is 'circlegrid', then `gridspec` is a 2-tuple
564
        specifying the radius and number of points around each point in the
565
        `pointdata` array.
566
    dir : str, optional
567
        Direction to draw streamlines: 'forward' to flow forward in time
568
        from the reference points, 'reverse' to flow backward in time, or
569
        'both' to flow both forward and backward.  The amount of time to
570
        simulate in each direction is given by the `timedata` argument.
571
    params : dict or list, optional
572
        Parameters to pass to system. For an I/O system, `params` should be
573
        a dict of parameters and values. For a callable, `params` should be
574
        dict with key 'args' and value given by a tuple (passed to callable).
575
    color : str
576
        Plot the streamlines in the given color.
577
    ax : `matplotlib.axes.Axes`, optional
578
        Use the given axes for the plot, otherwise use the current axes.
579

580
    Returns
581
    -------
582
    out : list of Line2D objects
583

584
    Other Parameters
585
    ----------------
586
    arrows : int
587
        Set the number of arrows to plot along the streamlines. The default
588
        value can be set in `config.defaults['phaseplot.arrows']`.
589
    arrow_size : float
590
        Set the size of arrows to plot along the streamlines.  The default
591
        value can be set in `config.defaults['phaseplot.arrow_size']`.
592
    arrow_style : matplotlib patch
593
        Set the style of arrows to plot along the streamlines.  The default
594
        value can be set in `config.defaults['phaseplot.arrow_style']`.
595
    rcParams : dict
596
        Override the default parameters used for generating plots.
597
        Default is set by `config.defaults['ctrlplot.rcParams']`.
598
    suppress_warnings : bool, optional
599
        If set to True, suppress warning messages in generating trajectories.
600
    zorder : float, optional
601
        Set the zorder for the streamlines.  In not specified, it will be
602
        automatically chosen by `matplotlib.axes.Axes.plot`.
603

604
    """
605
    # Process keywords
606
    rcParams = config._get_param('ctrlplot', 'rcParams', kwargs, pop=True)
9✔
607

608
    # Get system parameters
609
    params = kwargs.pop('params', None)
9✔
610

611
    # Create system from callable, if needed
612
    sys = _create_system(sys, params)
9✔
613

614
    # Parse the arrows keyword
615
    arrow_pos, arrow_style = _parse_arrow_keywords(kwargs)
9✔
616

617
    # Determine the points on which to generate the streamlines
618
    points, gridspec = _make_points(pointdata, gridspec, gridtype=gridtype)
9✔
619
    if dir is None:
9✔
620
        dir = 'both' if gridtype == 'meshgrid' else 'forward'
9✔
621

622
    # Create axis if needed
623
    if ax is None:
9✔
624
        ax = plt.gca()
9✔
625

626
    # Set the axis limits
627
    xlim, ylim, maxlim = _set_axis_limits(ax, pointdata)
9✔
628

629
    # Figure out the color to use
630
    color = _get_color(kwargs, ax=ax)
9✔
631

632
    # Make sure all keyword arguments were processed
633
    if _check_kwargs and kwargs:
9✔
634
        raise TypeError("unrecognized keywords: ", str(kwargs))
9✔
635

636
    # Create reverse time system, if needed
637
    if dir != 'forward':
9✔
638
        revsys = NonlinearIOSystem(
9✔
639
            lambda t, x, u, params: -np.asarray(sys.updfcn(t, x, u, params)),
640
            sys.outfcn, states=sys.nstates, inputs=sys.ninputs,
641
            outputs=sys.noutputs, params=sys.params)
642
    else:
643
        revsys = None
9✔
644

645
    # Generate phase plane (streamline) data
646
    out = []
9✔
647
    for i, X0 in enumerate(points):
9✔
648
        # Create the trajectory for this point
649
        timepts = _make_timepts(timedata, i)
9✔
650
        traj = _create_trajectory(
9✔
651
            sys, revsys, timepts, X0, params, dir,
652
            gridtype=gridtype, gridspec=gridspec, xlim=xlim, ylim=ylim,
653
            suppress_warnings=suppress_warnings)
654

655
        # Plot the trajectory (if there is one)
656
        if traj.shape[1] > 1:
9✔
657
            with plt.rc_context(rcParams):
9✔
658
                out += ax.plot(traj[0], traj[1], color=color, zorder=zorder)
9✔
659

660
                # Add arrows to the lines at specified intervals
661
                _add_arrows_to_line2D(
9✔
662
                    ax, out[-1], arrow_pos, arrowstyle=arrow_style, dir=1)
663
    return out
9✔
664

665

666
def equilpoints(
9✔
667
        sys, pointdata, gridspec=None, color='k', zorder=None, ax=None,
668
        _check_kwargs=True, **kwargs):
669
    """Plot equilibrium points in the phase plane.
670

671
    This function plots the equilibrium points for a planar dynamical system.
672

673
    Parameters
674
    ----------
675
    sys : `NonlinearIOSystem` or callable(t, x, ...)
676
        I/O system or function used to generate phase plane data. If a
677
        function is given, the remaining arguments are drawn from the
678
        `params` keyword.
679
    pointdata : list or 2D array
680
        List of the form [xmin, xmax, ymin, ymax] describing the
681
        boundaries of the phase plot or an array of shape (N, 2)
682
        giving points of at which to plot the vector field.
683
    gridtype : str, optional
684
        The type of grid to use for generating initial conditions:
685
        'meshgrid' (default) generates a mesh of initial conditions within
686
        the specified boundaries, 'boxgrid' generates initial conditions
687
        along the edges of the boundary, 'circlegrid' generates a circle of
688
        initial conditions around each point in point data.
689
    gridspec : list, optional
690
        If the gridtype is 'meshgrid' and 'boxgrid', `gridspec` gives the
691
        size of the grid in the x and y axes on which to generate points.
692
        If gridtype is 'circlegrid', then `gridspec` is a 2-tuple
693
        specifying the radius and number of points around each point in the
694
        `pointdata` array.
695
    params : dict or list, optional
696
        Parameters to pass to system. For an I/O system, `params` should be
697
        a dict of parameters and values. For a callable, `params` should be
698
        dict with key 'args' and value given by a tuple (passed to callable).
699
    color : str
700
        Plot the equilibrium points in the given color.
701
    ax : `matplotlib.axes.Axes`, optional
702
        Use the given axes for the plot, otherwise use the current axes.
703

704
    Returns
705
    -------
706
    out : list of Line2D objects
707

708
    Other Parameters
709
    ----------------
710
    rcParams : dict
711
        Override the default parameters used for generating plots.
712
        Default is set by `config.defaults['ctrlplot.rcParams']`.
713
    zorder : float, optional
714
        Set the zorder for the equilibrium points.  In not specified, it will
715
        be automatically chosen by `matplotlib.axes.Axes.plot`.
716

717
    """
718
    # Process keywords
719
    rcParams = config._get_param('ctrlplot', 'rcParams', kwargs, pop=True)
9✔
720

721
    # Get system parameters
722
    params = kwargs.pop('params', None)
9✔
723

724
    # Create system from callable, if needed
725
    sys = _create_system(sys, params)
9✔
726

727
    # Create axis if needed
728
    if ax is None:
9✔
729
        ax = plt.gca()
9✔
730

731
    # Set the axis limits
732
    xlim, ylim, maxlim = _set_axis_limits(ax, pointdata)
9✔
733

734
    # Determine the points on which to generate the vector field
735
    gridspec = [5, 5] if gridspec is None else gridspec
9✔
736
    points, _ = _make_points(pointdata, gridspec, 'meshgrid')
9✔
737

738
    # Make sure all keyword arguments were processed
739
    if _check_kwargs and kwargs:
9✔
740
        raise TypeError("unrecognized keywords: ", str(kwargs))
9✔
741

742
    # Search for equilibrium points
743
    equilpts = _find_equilpts(sys, points, params=params)
9✔
744

745
    # Plot the equilibrium points
746
    out = []
9✔
747
    for xeq in equilpts:
9✔
748
        with plt.rc_context(rcParams):
9✔
749
            out += ax.plot(
9✔
750
                xeq[0], xeq[1], marker='o', color=color, zorder=zorder)
751
    return out
9✔
752

753

754
def separatrices(
9✔
755
        sys, pointdata, timedata=None, gridspec=None, zorder=None, ax=None,
756
        _check_kwargs=True, suppress_warnings=False, **kwargs):
757
    """Plot separatrices in the phase plane.
758

759
    This function plots separatrices for a two-dimensional state space
760
    system.
761

762
    Parameters
763
    ----------
764
    sys : `NonlinearIOSystem` or callable(t, x, ...)
765
        I/O system or function used to generate phase plane data. If a
766
        function is given, the remaining arguments are drawn from the
767
        `params` keyword.
768
    pointdata : list or 2D array
769
        List of the form [xmin, xmax, ymin, ymax] describing the
770
        boundaries of the phase plot or an array of shape (N, 2)
771
        giving points of at which to plot the vector field.
772
    timedata : int, 1D array, or 2D array
773
        Time to simulate each streamline. If a 1D array is given, then the
774
        times at which to sample the simulation for all streamlines.
775
        If a 2D array is given, then for each row the times at which
776
        to sample the simulation for the corresponding streamline.
777
    gridtype : str, optional
778
        The type of grid to use for generating initial conditions:
779
        'meshgrid' (default) generates a mesh of initial conditions within
780
        the specified boundaries, 'boxgrid' generates initial conditions
781
        along the edges of the boundary, 'circlegrid' generates a circle of
782
        initial conditions around each point in point data.
783
    gridspec : list, optional
784
        If the gridtype is 'meshgrid' and 'boxgrid', `gridspec` gives the
785
        size of the grid in the x and y axes on which to generate points.
786
        If gridtype is 'circlegrid', then `gridspec` is a 2-tuple
787
        specifying the radius and number of points around each point in the
788
        `pointdata` array.
789
    params : dict or list, optional
790
        Parameters to pass to system. For an I/O system, `params` should be
791
        a dict of parameters and values. For a callable, `params` should be
792
        dict with key 'args' and value given by a tuple (passed to callable).
793
    color : matplotlib color spec, optional
794
        Plot the separatrices in the given color.  If a single color
795
        specification is given, this is used for both stable and unstable
796
        separatrices.  If a tuple is given, the first element is used as
797
        the color specification for stable separatrices and the second
798
        element for unstable separatrices.
799
    ax : `matplotlib.axes.Axes`, optional
800
        Use the given axes for the plot, otherwise use the current axes.
801

802
    Returns
803
    -------
804
    out : list of Line2D objects
805

806
    Other Parameters
807
    ----------------
808
    rcParams : dict
809
        Override the default parameters used for generating plots.
810
        Default is set by `config.defaults['ctrlplot.rcParams']`.
811
    suppress_warnings : bool, optional
812
        If set to True, suppress warning messages in generating trajectories.
813
    zorder : float, optional
814
        Set the zorder for the separatrices.  In not specified, it will be
815
        automatically chosen by `matplotlib.axes.Axes.plot`.
816

817
    Notes
818
    -----
819
    The value of `config.defaults['separatrices_radius']` is used to set the
820
    offset from the equilibrium point to the starting point of the separatix
821
    traces, in the direction of the eigenvectors evaluated at that
822
    equilibrium point.
823

824
    """
825
    # Process keywords
826
    rcParams = config._get_param('ctrlplot', 'rcParams', kwargs, pop=True)
9✔
827

828
    # Get system parameters
829
    params = kwargs.pop('params', None)
9✔
830

831
    # Create system from callable, if needed
832
    sys = _create_system(sys, params)
9✔
833

834
    # Parse the arrows keyword
835
    arrow_pos, arrow_style = _parse_arrow_keywords(kwargs)
9✔
836

837
    # Determine the initial states to use in searching for equilibrium points
838
    gridspec = [5, 5] if gridspec is None else gridspec
9✔
839
    points, _ = _make_points(pointdata, gridspec, 'meshgrid')
9✔
840

841
    # Find the equilibrium points
842
    equilpts = _find_equilpts(sys, points, params=params)
9✔
843
    radius = config._get_param('phaseplot', 'separatrices_radius')
9✔
844

845
    # Create axis if needed
846
    if ax is None:
9✔
847
        ax = plt.gca()
9✔
848

849
    # Set the axis limits
850
    xlim, ylim, maxlim = _set_axis_limits(ax, pointdata)
9✔
851

852
    # Figure out the color to use for stable, unstable subspaces
853
    color = _get_color(kwargs)
9✔
854
    match color:
9✔
855
        case None:
9✔
856
            stable_color = 'r'
9✔
857
            unstable_color = 'b'
9✔
858
        case (stable_color, unstable_color) | [stable_color, unstable_color]:
9✔
859
            pass
9✔
860
        case single_color:
9✔
861
            stable_color = unstable_color = single_color
9✔
862

863
    # Make sure all keyword arguments were processed
864
    if _check_kwargs and kwargs:
9✔
865
        raise TypeError("unrecognized keywords: ", str(kwargs))
9✔
866

867
    # Create a "reverse time" system to use for simulation
868
    revsys = NonlinearIOSystem(
9✔
869
        lambda t, x, u, params: -np.array(sys.updfcn(t, x, u, params)),
870
        sys.outfcn, states=sys.nstates, inputs=sys.ninputs,
871
        outputs=sys.noutputs, params=sys.params)
872

873
    # Plot separatrices by flowing backwards in time along eigenspaces
874
    out = []
9✔
875
    for i, xeq in enumerate(equilpts):
9✔
876
        # Figure out the linearization and eigenvectors
877
        evals, evecs = np.linalg.eig(sys.linearize(xeq, 0, params=params).A)
9✔
878

879
        # See if we have real eigenvalues (=> evecs are meaningful)
880
        if evals[0].imag > 0:
9✔
881
            continue
9✔
882

883
        # Create default list of time points
884
        if timedata is not None:
9✔
885
            timepts = _make_timepts(timedata, i)
9✔
886

887
        # Generate the traces
888
        for j, dir in enumerate(evecs.T):
9✔
889
            # Figure out time vector if not yet computed
890
            if timedata is None:
9✔
891
                timescale = math.log(maxlim / radius) / abs(evals[j].real)
9✔
892
                timepts = np.linspace(0, timescale)
9✔
893

894
            # Run the trajectory starting in eigenvector directions
895
            dir = dir.real      # use real components only
9✔
896
            for eps in [-radius, radius]:
9✔
897
                x0 = xeq + dir * eps
9✔
898
                if evals[j].real < 0:
9✔
899
                    traj = _create_trajectory(
9✔
900
                        sys, revsys, timepts, x0, params, 'reverse',
901
                        gridtype='boxgrid', xlim=xlim, ylim=ylim,
902
                        suppress_warnings=suppress_warnings)
903
                    color = stable_color
9✔
904
                    linestyle = '--'
9✔
905
                elif evals[j].real > 0:
9✔
906
                    traj = _create_trajectory(
9✔
907
                        sys, revsys, timepts, x0, params, 'forward',
908
                        gridtype='boxgrid', xlim=xlim, ylim=ylim,
909
                        suppress_warnings=suppress_warnings)
910
                    color = unstable_color
9✔
911
                    linestyle = '-'
9✔
912

913
                # Plot the trajectory (if there is one)
914
                if traj.shape[1] > 1:
9✔
915
                    with plt.rc_context(rcParams):
9✔
916
                        out += ax.plot(
9✔
917
                            traj[0], traj[1], color=color,
918
                            linestyle=linestyle, zorder=zorder)
919

920
                    # Add arrows to the lines at specified intervals
921
                    with plt.rc_context(rcParams):
9✔
922
                        _add_arrows_to_line2D(
9✔
923
                            ax, out[-1], arrow_pos, arrowstyle=arrow_style,
924
                            dir=1)
925
    return out
9✔
926

927

928
#
929
# User accessible utility functions
930
#
931

932
# Utility function to generate boxgrid (in the form needed here)
933
def boxgrid(xvals, yvals):
9✔
934
    """Generate list of points along the edge of box.
935

936
    points = boxgrid(xvals, yvals) generates a list of points that
937
    corresponds to a grid given by the cross product of the x and y values.
938

939
    Parameters
940
    ----------
941
    xvals, yvals : 1D array_like
942
        Array of points defining the points on the lower and left edges of
943
        the box.
944

945
    Returns
946
    -------
947
    grid : 2D array
948
        Array with shape (p, 2) defining the points along the edges of the
949
        box, where p is the number of points around the edge.
950

951
    """
952
    return np.array(
9✔
953
        [(x, yvals[0]) for x in xvals[:-1]] +           # lower edge
954
        [(xvals[-1], y) for y in yvals[:-1]] +          # right edge
955
        [(x, yvals[-1]) for x in xvals[:0:-1]] +        # upper edge
956
        [(xvals[0], y) for y in yvals[:0:-1]]           # left edge
957
    )
958

959

960
# Utility function to generate meshgrid (in the form needed here)
961
# TODO: add examples of using grid functions directly
962
def meshgrid(xvals, yvals):
9✔
963
    """Generate list of points forming a mesh.
964

965
    points = meshgrid(xvals, yvals) generates a list of points that
966
    corresponds to a grid given by the cross product of the x and y values.
967

968
    Parameters
969
    ----------
970
    xvals, yvals : 1D array_like
971
        Array of points defining the points on the lower and left edges of
972
        the box.
973

974
    Returns
975
    -------
976
    grid : 2D array
977
        Array of points with shape (n * m, 2) defining the mesh.
978

979
    """
980
    xvals, yvals = np.meshgrid(xvals, yvals)
9✔
981
    grid = np.zeros((xvals.shape[0] * xvals.shape[1], 2))
9✔
982
    grid[:, 0] = xvals.reshape(-1)
9✔
983
    grid[:, 1] = yvals.reshape(-1)
9✔
984

985
    return grid
9✔
986

987

988
# Utility function to generate circular grid
989
def circlegrid(centers, radius, num):
9✔
990
    """Generate list of points around a circle.
991

992
    points = circlegrid(centers, radius, num) generates a list of points
993
    that form a circle around a list of centers.
994

995
    Parameters
996
    ----------
997
    centers : 2D array_like
998
        Array of points with shape (p, 2) defining centers of the circles.
999
    radius : float
1000
        Radius of the points to be generated around each center.
1001
    num : int
1002
        Number of points to generate around the circle.
1003

1004
    Returns
1005
    -------
1006
    grid : 2D array
1007
        Array of points with shape (p * num, 2) defining the circles.
1008

1009
    """
1010
    centers = np.atleast_2d(np.array(centers))
9✔
1011
    grid = np.zeros((centers.shape[0] * num, 2))
9✔
1012
    for i, center in enumerate(centers):
9✔
1013
        grid[i * num: (i + 1) * num, :] = center + np.array([
9✔
1014
            [radius * math.cos(theta), radius * math.sin(theta)] for
1015
            theta in np.linspace(0, 2 * math.pi, num, endpoint=False)])
1016
    return grid
9✔
1017

1018

1019
#
1020
# Internal utility functions
1021
#
1022

1023
# Create a system from a callable
1024
def _create_system(sys, params):
9✔
1025
    if isinstance(sys, NonlinearIOSystem):
9✔
1026
        if sys.nstates != 2:
9✔
1027
            raise ValueError("system must be planar")
9✔
1028
        return sys
9✔
1029

1030
    # Make sure that if params is present, it has 'args' key
1031
    if params and not params.get('args', None):
9✔
1032
        raise ValueError("params must be dict with key 'args'")
9✔
1033

1034
    _update = lambda t, x, u, params: sys(t, x, *params.get('args', ()))
9✔
1035
    _output = lambda t, x, u, params: np.array([])
9✔
1036
    return NonlinearIOSystem(
9✔
1037
        _update, _output, states=2, inputs=0, outputs=0, name="_callable")
1038

1039

1040
# Set axis limits for the plot
1041
def _set_axis_limits(ax, pointdata):
9✔
1042
    # Get the current axis limits
1043
    if ax.lines:
9✔
1044
        xlim, ylim = ax.get_xlim(), ax.get_ylim()
9✔
1045
    else:
1046
        # Nothing on the plot => always use new limits
1047
        xlim, ylim = [np.inf, -np.inf], [np.inf, -np.inf]
9✔
1048

1049
    # Short utility function for updating axis limits
1050
    def _update_limits(cur, new):
9✔
1051
        return [min(cur[0], np.min(new)), max(cur[1], np.max(new))]
9✔
1052

1053
    # If we were passed a box, use that to update the limits
1054
    if isinstance(pointdata, list) and len(pointdata) == 4:
9✔
1055
        xlim = _update_limits(xlim, [pointdata[0], pointdata[1]])
9✔
1056
        ylim = _update_limits(ylim, [pointdata[2], pointdata[3]])
9✔
1057

1058
    elif isinstance(pointdata, np.ndarray):
9✔
1059
        pointdata = np.atleast_2d(pointdata)
9✔
1060
        xlim = _update_limits(
9✔
1061
            xlim, [np.min(pointdata[:, 0]), np.max(pointdata[:, 0])])
1062
        ylim = _update_limits(
9✔
1063
            ylim, [np.min(pointdata[:, 1]), np.max(pointdata[:, 1])])
1064

1065
    # Keep track of the largest dimension on the plot
1066
    maxlim = max(xlim[1] - xlim[0], ylim[1] - ylim[0])
9✔
1067

1068
    # Set the new limits
1069
    ax.autoscale(enable=True, axis='x', tight=True)
9✔
1070
    ax.autoscale(enable=True, axis='y', tight=True)
9✔
1071
    ax.set_xlim(xlim)
9✔
1072
    ax.set_ylim(ylim)
9✔
1073

1074
    return xlim, ylim, maxlim
9✔
1075

1076

1077
# Find equilibrium points
1078
def _find_equilpts(sys, points, params=None):
9✔
1079
    equilpts = []
9✔
1080
    for i, x0 in enumerate(points):
9✔
1081
        # Look for an equilibrium point near this point
1082
        xeq, ueq = find_operating_point(sys, x0, 0, params=params)
9✔
1083

1084
        if xeq is None:
9✔
1085
            continue            # didn't find anything
9✔
1086

1087
        # See if we have already found this point
1088
        seen = False
9✔
1089
        for x in equilpts:
9✔
1090
            if np.allclose(np.array(x), xeq):
9✔
1091
                seen = True
9✔
1092
        if seen:
9✔
1093
            continue
9✔
1094

1095
        # Save a new point
1096
        equilpts += [xeq.tolist()]
9✔
1097

1098
    return equilpts
9✔
1099

1100

1101
def _make_points(pointdata, gridspec, gridtype):
9✔
1102
    # Check to see what type of data we got
1103
    if isinstance(pointdata, np.ndarray) and gridtype is None:
9✔
1104
        pointdata = np.atleast_2d(pointdata)
9✔
1105
        if pointdata.shape[1] == 2:
9✔
1106
            # Given a list of points => no action required
1107
            return pointdata, None
9✔
1108

1109
    # Utility function to parse (and check) input arguments
1110
    def _parse_args(defsize):
9✔
1111
        if gridspec is None:
9✔
1112
            return defsize
9✔
1113

1114
        elif not isinstance(gridspec, (list, tuple)) or \
9✔
1115
             len(gridspec) != len(defsize):
1116
            raise ValueError("invalid grid specification")
9✔
1117

1118
        return gridspec
9✔
1119

1120
    # Generate points based on grid type
1121
    match gridtype:
9✔
1122
        case 'boxgrid' | None:
9✔
1123
            gridspec = _parse_args([6, 4])
9✔
1124
            points = boxgrid(
9✔
1125
                np.linspace(pointdata[0], pointdata[1], gridspec[0]),
1126
                np.linspace(pointdata[2], pointdata[3], gridspec[1]))
1127

1128
        case 'meshgrid':
9✔
1129
            gridspec = _parse_args([9, 6])
9✔
1130
            points = meshgrid(
9✔
1131
                np.linspace(pointdata[0], pointdata[1], gridspec[0]),
1132
                np.linspace(pointdata[2], pointdata[3], gridspec[1]))
1133

1134
        case 'circlegrid':
9✔
1135
            gridspec = _parse_args((0.5, 10))
9✔
1136
            if isinstance(pointdata, np.ndarray):
9✔
1137
                # Create circles around each point
1138
                points = circlegrid(pointdata, gridspec[0], gridspec[1])
9✔
1139
            else:
1140
                # Create circle around center of the plot
1141
                points = circlegrid(
9✔
1142
                    np.array(
1143
                        [(pointdata[0] + pointdata[1]) / 2,
1144
                         (pointdata[0] + pointdata[1]) / 2]),
1145
                    gridspec[0], gridspec[1])
1146

1147
        case _:
9✔
1148
            raise ValueError(f"unknown grid type '{gridtype}'")
9✔
1149

1150
    return points, gridspec
9✔
1151

1152

1153
def _parse_arrow_keywords(kwargs):
9✔
1154
    # Get values for params (and pop from list to allow keyword use in plot)
1155
    # TODO: turn this into a utility function (shared with nyquist_plot?)
1156
    arrows = config._get_param(
9✔
1157
        'phaseplot', 'arrows', kwargs, None, pop=True)
1158
    arrow_size = config._get_param(
9✔
1159
        'phaseplot', 'arrow_size', kwargs, None, pop=True)
1160
    arrow_style = config._get_param('phaseplot', 'arrow_style', kwargs, None)
9✔
1161

1162
    # Parse the arrows keyword
1163
    if not arrows:
9✔
1164
        arrow_pos = []
×
1165
    elif isinstance(arrows, int):
9✔
1166
        N = arrows
9✔
1167
        # Space arrows out, starting midway along each "region"
1168
        arrow_pos = np.linspace(0.5/N, 1 + 0.5/N, N, endpoint=False)
9✔
1169
    elif isinstance(arrows, (list, np.ndarray)):
×
1170
        arrow_pos = np.sort(np.atleast_1d(arrows))
×
1171
    else:
1172
        raise ValueError("unknown or unsupported arrow location")
×
1173

1174
    # Set the arrow style
1175
    if arrow_style is None:
9✔
1176
        arrow_style = mpl.patches.ArrowStyle(
9✔
1177
            'simple', head_width=int(2 * arrow_size / 3),
1178
            head_length=arrow_size)
1179

1180
    return arrow_pos, arrow_style
9✔
1181

1182

1183
# TODO: move to ctrlplot?
1184
def _create_trajectory(
9✔
1185
        sys, revsys, timepts, X0, params, dir, suppress_warnings=False,
1186
        gridtype=None, gridspec=None, xlim=None, ylim=None):
1187
    # Compute the forward trajectory
1188
    if dir == 'forward' or dir == 'both':
9✔
1189
        fwdresp = input_output_response(
9✔
1190
            sys, timepts, initial_state=X0, params=params, ignore_errors=True)
1191
        if not fwdresp.success and not suppress_warnings:
9✔
1192
            warnings.warn(f"initial_state={X0}, {fwdresp.message}")
9✔
1193

1194
    # Compute the reverse trajectory
1195
    if dir == 'reverse' or dir == 'both':
9✔
1196
        revresp = input_output_response(
9✔
1197
            revsys, timepts, initial_state=X0, params=params,
1198
            ignore_errors=True)
1199
        if not revresp.success and not suppress_warnings:
9✔
1200
            warnings.warn(f"initial_state={X0}, {revresp.message}")
×
1201

1202
    # Create the trace to plot
1203
    if dir == 'forward':
9✔
1204
        traj = fwdresp.states
9✔
1205
    elif dir == 'reverse':
9✔
1206
        traj = revresp.states[:, ::-1]
9✔
1207
    elif dir == 'both':
9✔
1208
        traj = np.hstack([revresp.states[:, :1:-1], fwdresp.states])
9✔
1209

1210
    # Remove points outside the window (keep first point beyond boundary)
1211
    inrange = np.asarray(
9✔
1212
        (traj[0] >= xlim[0]) & (traj[0] <= xlim[1]) &
1213
        (traj[1] >= ylim[0]) & (traj[1] <= ylim[1]))
1214
    inrange[:-1] = inrange[:-1] | inrange[1:]   # keep if next point in range
9✔
1215
    inrange[1:] = inrange[1:] | inrange[:-1]    # keep if prev point in range
9✔
1216

1217
    return traj[:, inrange]
9✔
1218

1219

1220
def _make_timepts(timepts, i):
9✔
1221
    if timepts is None:
9✔
1222
        return np.linspace(0, 1)
9✔
1223
    elif isinstance(timepts, (int, float)):
9✔
1224
        return np.linspace(0, timepts)
9✔
1225
    elif timepts.ndim == 2:
×
1226
        return timepts[i]
×
1227
    return timepts
×
1228

1229

1230
#
1231
# Legacy phase plot function
1232
#
1233
# Author: Richard Murray
1234
# Date: 24 July 2011, converted from MATLAB version (2002); based on
1235
# a version by Kristi Morgansen
1236
#
1237
def phase_plot(odefun, X=None, Y=None, scale=1, X0=None, T=None,
9✔
1238
               lingrid=None, lintime=None, logtime=None, timepts=None,
1239
               parms=None, params=(), tfirst=False, verbose=True):
1240

1241
    """(legacy) Phase plot for 2D dynamical systems.
1242

1243
    .. deprecated:: 0.10.1
1244
        This function is deprecated; use `phase_plane_plot` instead.
1245

1246
    Produces a vector field or stream line plot for a planar system.  This
1247
    function has been replaced by the `phase_plane_map` and
1248
    `phase_plane_plot` functions.
1249

1250
    Call signatures:
1251
      phase_plot(func, X, Y, ...) - display vector field on meshgrid
1252
      phase_plot(func, X, Y, scale, ...) - scale arrows
1253
      phase_plot(func. X0=(...), T=Tmax, ...) - display stream lines
1254
      phase_plot(func, X, Y, X0=[...], T=Tmax, ...) - plot both
1255
      phase_plot(func, X0=[...], T=Tmax, lingrid=N, ...) - plot both
1256
      phase_plot(func, X0=[...], lintime=N, ...) - stream lines with arrows
1257

1258
    Parameters
1259
    ----------
1260
    func : callable(x, t, ...)
1261
        Computes the time derivative of y (compatible with odeint).  The
1262
        function should be the same for as used for `scipy.integrate`.
1263
        Namely, it should be a function of the form dx/dt = F(t, x) that
1264
        accepts a state x of dimension 2 and returns a derivative dx/dt of
1265
        dimension 2.
1266
    X, Y: 3-element sequences, optional, as [start, stop, npts]
1267
        Two 3-element sequences specifying x and y coordinates of a
1268
        grid.  These arguments are passed to linspace and meshgrid to
1269
        generate the points at which the vector field is plotted.  If
1270
        absent (or None), the vector field is not plotted.
1271
    scale: float, optional
1272
        Scale size of arrows; default = 1
1273
    X0: ndarray of initial conditions, optional
1274
        List of initial conditions from which streamlines are plotted.
1275
        Each initial condition should be a pair of numbers.
1276
    T: array_like or number, optional
1277
        Length of time to run simulations that generate streamlines.
1278
        If a single number, the same simulation time is used for all
1279
        initial conditions.  Otherwise, should be a list of length
1280
        len(X0) that gives the simulation time for each initial
1281
        condition.  Default value = 50.
1282
    lingrid : integer or 2-tuple of integers, optional
1283
        Argument is either N or (N, M).  If X0 is given and X, Y are
1284
        missing, a grid of arrows is produced using the limits of the
1285
        initial conditions, with N grid points in each dimension or N grid
1286
        points in x and M grid points in y.
1287
    lintime : integer or tuple (integer, float), optional
1288
        If a single integer N is given, draw N arrows using equally space
1289
        time points.  If a tuple (N, lambda) is given, draw N arrows using
1290
        exponential time constant lambda
1291
    timepts : array_like, optional
1292
        Draw arrows at the given list times [t1, t2, ...]
1293
    tfirst : bool, optional
1294
        If True, call `func` with signature ``func(t, x, ...)``.
1295
    params: tuple, optional
1296
        List of parameters to pass to vector field: ``func(x, t, *params)``.
1297

1298
    See Also
1299
    --------
1300
    box_grid
1301

1302
    """
1303
    # Generate a deprecation warning
1304
    warnings.warn(
9✔
1305
        "phase_plot() is deprecated; use phase_plane_plot() instead",
1306
        FutureWarning)
1307

1308
    #
1309
    # Figure out ranges for phase plot (argument processing)
1310
    #
1311
    #! TODO: need to add error checking to arguments
1312
    #! TODO: think through proper action if multiple options are given
1313
    #
1314
    autoFlag = False
9✔
1315
    logtimeFlag = False
9✔
1316
    timeptsFlag = False
9✔
1317
    Narrows = 0
9✔
1318

1319
    # Get parameters to pass to function
1320
    if parms:
9✔
1321
        warnings.warn(
9✔
1322
            "keyword 'parms' is deprecated; use 'params'", FutureWarning)
1323
        if params:
9✔
1324
            raise ControlArgument("duplicate keywords 'parms' and 'params'")
×
1325
        else:
1326
            params = parms
9✔
1327

1328
    if lingrid is not None:
9✔
1329
        autoFlag = True
9✔
1330
        Narrows = lingrid
9✔
1331
        if (verbose):
9✔
1332
            print('Using auto arrows\n')
×
1333

1334
    elif logtime is not None:
9✔
1335
        logtimeFlag = True
9✔
1336
        Narrows = logtime[0]
9✔
1337
        timefactor = logtime[1]
9✔
1338
        if (verbose):
9✔
1339
            print('Using logtime arrows\n')
×
1340

1341
    elif timepts is not None:
9✔
1342
        timeptsFlag = True
9✔
1343
        Narrows = len(timepts)
9✔
1344

1345
    # Figure out the set of points for the quiver plot
1346
    #! TODO: Add sanity checks
1347
    elif X is not None and Y is not None:
9✔
1348
        x1, x2 = np.meshgrid(
9✔
1349
            np.linspace(X[0], X[1], X[2]),
1350
            np.linspace(Y[0], Y[1], Y[2]))
1351
        Narrows = len(x1)
9✔
1352

1353
    else:
1354
        # If we weren't given any grid points, don't plot arrows
1355
        Narrows = 0
9✔
1356

1357
    if not autoFlag and not logtimeFlag and not timeptsFlag and Narrows > 0:
9✔
1358
        # Now calculate the vector field at those points
1359
        (nr,nc) = x1.shape
9✔
1360
        dx = np.empty((nr, nc, 2))
9✔
1361
        for i in range(nr):
9✔
1362
            for j in range(nc):
9✔
1363
                if tfirst:
9✔
1364
                    dx[i, j, :] = np.squeeze(
×
1365
                        odefun(0, [x1[i,j], x2[i,j]], *params))
1366
                else:
1367
                    dx[i, j, :] = np.squeeze(
9✔
1368
                        odefun([x1[i,j], x2[i,j]], 0, *params))
1369

1370
        # Plot the quiver plot
1371
        #! TODO: figure out arguments to make arrows show up correctly
1372
        if scale is None:
9✔
1373
            plt.quiver(x1, x2, dx[:,:,1], dx[:,:,2], angles='xy')
×
1374
        elif (scale != 0):
9✔
1375
            plt.quiver(x1, x2, dx[:,:,0]*np.abs(scale),
9✔
1376
                       dx[:,:,1]*np.abs(scale), angles='xy')
1377
            #! TODO: optimize parameters for arrows
1378
            #! TODO: figure out arguments to make arrows show up correctly
1379
            # xy = plt.quiver(...)
1380
            # set(xy, 'LineWidth', PP_arrow_linewidth, 'Color', 'b')
1381

1382
        #! TODO: Tweak the shape of the plot
1383
        # a=gca; set(a,'DataAspectRatio',[1,1,1])
1384
        # set(a,'XLim',X(1:2)); set(a,'YLim',Y(1:2))
1385
        plt.xlabel('x1'); plt.ylabel('x2')
9✔
1386

1387
    # See if we should also generate the streamlines
1388
    if X0 is None or len(X0) == 0:
9✔
1389
        return
9✔
1390

1391
    # Convert initial conditions to a numpy array
1392
    X0 = np.array(X0)
9✔
1393
    (nr, nc) = np.shape(X0)
9✔
1394

1395
    # Generate some empty matrices to keep arrow information
1396
    x1 = np.empty((nr, Narrows))
9✔
1397
    x2 = np.empty((nr, Narrows))
9✔
1398
    dx = np.empty((nr, Narrows, 2))
9✔
1399

1400
    # See if we were passed a simulation time
1401
    if T is None:
9✔
1402
        T = 50
9✔
1403

1404
    # Parse the time we were passed
1405
    TSPAN = T
9✔
1406
    if isinstance(T, (int, float)):
9✔
1407
        TSPAN = np.linspace(0, T, 100)
9✔
1408

1409
    # Figure out the limits for the plot
1410
    if scale is None:
9✔
1411
        # Assume that the current axis are set as we want them
1412
        alim = plt.axis()
×
1413
        xmin = alim[0]; xmax = alim[1]
×
1414
        ymin = alim[2]; ymax = alim[3]
×
1415
    else:
1416
        # Use the maximum extent of all trajectories
1417
        xmin = np.min(X0[:,0]); xmax = np.max(X0[:,0])
9✔
1418
        ymin = np.min(X0[:,1]); ymax = np.max(X0[:,1])
9✔
1419

1420
    # Generate the streamlines for each initial condition
1421
    for i in range(nr):
9✔
1422
        state = odeint(odefun, X0[i], TSPAN, args=params, tfirst=tfirst)
9✔
1423
        time = TSPAN
9✔
1424

1425
        plt.plot(state[:,0], state[:,1])
9✔
1426
        #! TODO: add back in colors for stream lines
1427
        # PP_stream_color(np.mod(i-1, len(PP_stream_color))+1))
1428
        # set(h[i], 'LineWidth', PP_stream_linewidth)
1429

1430
        # Plot arrows if quiver parameters were 'auto'
1431
        if autoFlag or logtimeFlag or timeptsFlag:
9✔
1432
            # Compute the locations of the arrows
1433
            #! TODO: check this logic to make sure it works in python
1434
            for j in range(Narrows):
9✔
1435

1436
                # Figure out starting index; headless arrows start at 0
1437
                k = -1 if scale is None else 0
9✔
1438

1439
                # Figure out what time index to use for the next point
1440
                if autoFlag:
9✔
1441
                    # Use a linear scaling based on ODE time vector
1442
                    tind = np.floor((len(time)/Narrows) * (j-k)) + k
×
1443
                elif logtimeFlag:
9✔
1444
                    # Use an exponential time vector
1445
                    # MATLAB: tind = find(time < (j-k) / lambda, 1, 'last')
1446
                    tarr = _find(time < (j-k) / timefactor)
9✔
1447
                    tind = tarr[-1] if len(tarr) else 0
9✔
1448
                elif timeptsFlag:
9✔
1449
                    # Use specified time points
1450
                    # MATLAB: tind = find(time < Y[j], 1, 'last')
1451
                    tarr = _find(time < timepts[j])
9✔
1452
                    tind = tarr[-1] if len(tarr) else 0
9✔
1453

1454
                # For tailless arrows, skip the first point
1455
                if tind == 0 and scale is None:
9✔
1456
                    continue
×
1457

1458
                # Figure out the arrow at this point on the curve
1459
                x1[i,j] = state[tind, 0]
9✔
1460
                x2[i,j] = state[tind, 1]
9✔
1461

1462
                # Skip arrows outside of initial condition box
1463
                if (scale is not None or
9✔
1464
                     (x1[i,j] <= xmax and x1[i,j] >= xmin and
1465
                      x2[i,j] <= ymax and x2[i,j] >= ymin)):
1466
                    if tfirst:
9✔
1467
                        pass
×
1468
                        v = odefun(0, [x1[i,j], x2[i,j]], *params)
×
1469
                    else:
1470
                        v = odefun([x1[i,j], x2[i,j]], 0, *params)
9✔
1471
                    dx[i, j, 0] = v[0]; dx[i, j, 1] = v[1]
9✔
1472
                else:
1473
                    dx[i, j, 0] = 0; dx[i, j, 1] = 0
×
1474

1475
    # Set the plot shape before plotting arrows to avoid warping
1476
    # a=gca
1477
    # if (scale != None):
1478
    #     set(a,'DataAspectRatio', [1,1,1])
1479
    # if (xmin != xmax and ymin != ymax):
1480
    #     plt.axis([xmin, xmax, ymin, ymax])
1481
    # set(a, 'Box', 'on')
1482

1483
    # Plot arrows on the streamlines
1484
    if scale is None and Narrows > 0:
9✔
1485
        # Use a tailless arrow
1486
        #! TODO: figure out arguments to make arrows show up correctly
1487
        plt.quiver(x1, x2, dx[:,:,0], dx[:,:,1], angles='xy')
×
1488
    elif scale != 0 and Narrows > 0:
9✔
1489
        plt.quiver(x1, x2, dx[:,:,0]*abs(scale), dx[:,:,1]*abs(scale),
9✔
1490
                   angles='xy')
1491
        #! TODO: figure out arguments to make arrows show up correctly
1492
        # xy = plt.quiver(...)
1493
        # set(xy, 'LineWidth', PP_arrow_linewidth)
1494
        # set(xy, 'AutoScale', 'off')
1495
        # set(xy, 'AutoScaleFactor', 0)
1496

1497
    if scale < 0:
9✔
1498
        plt.plot(x1, x2, 'b.');        # add dots at base
×
1499
        # bp = plt.plot(...)
1500
        # set(bp, 'MarkerSize', PP_arrow_markersize)
1501

1502

1503
# Utility function for generating initial conditions around a box
1504
def box_grid(xlimp, ylimp):
9✔
1505
    """Generate list of points on edge of box.
1506

1507
    .. deprecated:: 0.10.0
1508
        Use `phaseplot.boxgrid` instead.
1509

1510
    list = box_grid([xmin xmax xnum], [ymin ymax ynum]) generates a
1511
    list of points that correspond to a uniform grid at the end of the
1512
    box defined by the corners [xmin ymin] and [xmax ymax].
1513

1514
    """
1515

1516
    # Generate a deprecation warning
1517
    warnings.warn(
×
1518
        "box_grid() is deprecated; use phaseplot.boxgrid() instead",
1519
        FutureWarning)
1520

1521
    return boxgrid(
×
1522
        np.linspace(xlimp[0], xlimp[1], xlimp[2]),
1523
        np.linspace(ylimp[0], ylimp[1], ylimp[2]))
1524

1525

1526
# TODO: rename to something more useful (or remove??)
1527
def _find(condition):
9✔
1528
    """Returns indices where ravel(a) is true.
1529

1530
    Private implementation of deprecated `matplotlib.mlab.find`.
1531

1532
    """
1533
    return np.nonzero(np.ravel(condition))[0]
9✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc