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

python-control / python-control / 31258558615

08 Aug 2026 01:00PM UTC coverage: 94.579%. Remained the same
31258558615

push

github

web-flow
Merge pull request #1197 from FeldrinH/main

Correct timedata doc comment for phase plot functions

9944 of 10514 relevant lines covered (94.58%)

3.67 hits per line

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

93.38
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
4✔
24
import warnings
4✔
25

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

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

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

40
# Default values for module parameter variables
41
_phaseplot_defaults = {
4✔
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(
4✔
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 = [
4✔
177
        'arrows', 'arrow_size', 'arrow_style', 'dir']
178
    if plot_streamlines is None:
4✔
179
        if any([kw in kwargs for kw in streamline_keywords]):
4✔
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']:
4✔
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 \
4✔
191
       and plot_streamplot is None:
192
        plot_streamplot = True
4✔
193

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

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

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

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

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

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

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

224
    # Plot out the main elements
225
    if plot_streamlines:
4✔
226
        kwargs_local = _create_kwargs(
4✔
227
            kwargs, plot_streamlines, gridspec=gridspec, gridtype=gridtype,
228
            ax=ax)
229
        out[0] += streamlines(
4✔
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])
4✔
234
        flow_zorder = max(flow_zorder, new_zorder) if flow_zorder \
4✔
235
            else new_zorder
236

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

321

322
def vectorfield(
4✔
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)
4✔
379

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

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

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

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

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

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

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

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

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

415
    return out
4✔
416

417

418
def streamplot(
4✔
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)
4✔
478

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

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

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

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

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

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

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

505
    # Generate phase plane (quiver) data
506
    sys._update_params(params)
4✔
507
    us_flat, vs_flat = np.transpose(
4✔
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)
4✔
510

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

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

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

527
    return out
4✔
528

529

530
def streamlines(
4✔
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)
4✔
607

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

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

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

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

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

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

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

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

636
    # Create reverse time system, if needed
637
    if dir != 'forward':
4✔
638
        revsys = NonlinearIOSystem(
4✔
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
4✔
644

645
    # Generate phase plane (streamline) data
646
    out = []
4✔
647
    for i, X0 in enumerate(points):
4✔
648
        # Create the trajectory for this point
649
        timepts = _make_timepts(timedata, i)
4✔
650
        traj = _create_trajectory(
4✔
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:
4✔
657
            with plt.rc_context(rcParams):
4✔
658
                out += ax.plot(traj[0], traj[1], color=color, zorder=zorder)
4✔
659

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

665

666
def equilpoints(
4✔
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)
4✔
720

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

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

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

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

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

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

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

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

753

754
def separatrices(
4✔
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)
4✔
827

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

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

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

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

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

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

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

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

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

867
    # Create a "reverse time" system to use for simulation
868
    revsys = NonlinearIOSystem(
4✔
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 = []
4✔
875
    for i, xeq in enumerate(equilpts):
4✔
876
        # Figure out the linearization and eigenvectors
877
        evals, evecs = np.linalg.eig(sys.linearize(xeq, 0, params=params).A)
4✔
878

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

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

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

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

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

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

926

927
#
928
# User accessible utility functions
929
#
930

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

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

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

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

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

958

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

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

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

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

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

984
    return grid
4✔
985

986

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

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

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

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

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

1017

1018
#
1019
# Internal utility functions
1020
#
1021

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

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

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

1038

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

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

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

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

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

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

1073
    return xlim, ylim, maxlim
4✔
1074

1075

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

1083
        if xeq is None:
4✔
1084
            continue            # didn't find anything
4✔
1085

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

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

1097
    return equilpts
4✔
1098

1099

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

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

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

1117
        return gridspec
4✔
1118

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

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

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

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

1149
    return points, gridspec
4✔
1150

1151

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

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

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

1179
    return arrow_pos, arrow_style
4✔
1180

1181

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

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

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

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

1216
    return traj[:, inrange]
4✔
1217

1218

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

1228

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

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

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

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

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

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

1297
    See Also
1298
    --------
1299
    box_grid
1300

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

1501

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

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

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

1513
    """
1514

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

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

1524

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

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

1531
    """
1532
    return np.nonzero(np.ravel(condition))[0]
4✔
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