8000 Time response plot improvements by murrayrm · Pull Request #1018 · python-control/python-control · GitHub
[go: up one dir, main page]

Skip to content

Time response plot improvements #1018

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
92 changes: 90 additions & 2 deletions control/ctrlplot.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,14 @@
#
# Collection of functions that are used by various plotting functions.

from os.path import commonprefix

import matplotlib.pyplot as plt
import numpy as np

from . import config

__all__ = ['suptitle']
__all__ = ['suptitle', 'get_plot_axes']


def suptitle(
Expand Down Expand Up @@ -44,7 +46,6 @@ def suptitle(

elif frame == 'axes':
# TODO: move common plotting params to 'ctrlplot'
rcParams = config._get_param('freqplot', 'rcParams', rcParams)
with plt.rc_context(rcParams):
plt.tight_layout() # Put the figure into proper layout
xc, _ = _find_axes_center(fig, fig.get_axes())
Expand All @@ -56,6 +57,93 @@ def suptitle(
raise ValueError(f"unknown frame '{frame}'")


# Create vectorized function to find axes from lines
def get_plot_axes(line_array):
"""Get a list of axes from an array of lines.

This function can be used to return the set of axes corresponding to
the line array that is returned by `time_response_plot`. This is useful for
generating an axes array that can be passed to subsequent plotting
calls.

Parameters
----------
line_array : array of list of Line2D
A 2D array with elements corresponding to a list of lines appearing
in an axes, matching the return type of a time response data plot.

Returns
-------
axes_array : array of list of Axes
A 2D array with elements corresponding to the Axes assocated with
the lines in `line_array`.

Notes
-----
Only the first element of each array entry is used to determine the axes.

"""
_get_axes = np.vectorize(lambda lines: lines[0].axes)
return _get_axes(line_array)

#
# Utility functions
#


# Utility function to make legend labels
def _make_legend_labels(labels, ignore_common=False):

# Look for a common prefix (up to a space)
common_prefix = commonprefix(labels)
last_space = common_prefix.rfind(', ')
if last_space < 0 or ignore_common:
common_prefix = ''
elif last_space > 0:
common_prefix = common_prefix[:last_space]
prefix_len = len(common_prefix)

# Look for a common suffix (up to a space)
common_suffix = commonprefix(
[label[::-1] for label in labels])[::-1]
suffix_len = len(common_suffix)
# Only chop things off after a comma or space
while suffix_len > 0 and common_suffix[-suffix_len] != ',':
suffix_len -= 1

# Strip the labels of common information
if suffix_len > 0 and not ignore_common:
labels = [label[prefix_len:-suffix_len] for label in labels]
else:
labels = [label[prefix_len:] for label in labels]

return labels


def _update_suptitle(fig, title, rcParams=None, frame='axes'):
if fig is not None and isinstance(title, str):
# Get the current title, if it exists
old_title = None if fig._suptitle is None else fig._suptitle._text

if old_title is not None:
# Find the common part of the titles
common_prefix = commonprefix([old_title, title])

# Back up to the last space
last_space = common_prefix.rfind(' ')
if last_space > 0:
common_prefix = common_prefix[:last_space]
common_len = len(common_prefix)

# Add the new part of the title (usually the system name)
if old_title[common_len:] != title[common_len:]:
separator = ',' if len(common_prefix) > 0 else ';'
title = old_title + separator + title[common_len:]

# Add the title
suptitle(title, fig=fig, rcParams=rcParams, frame=frame)


def _find_axes_center(fig, axs):
"""Find the midpoint between axes in display coordinates.

Expand Down
8 changes: 8 additions & 0 deletions control/frdata.py
Original file line number Diff line number Diff line change
Expand Up @@ -653,6 +653,14 @@ def plot(self, plot_type=None, *args, **kwargs):

# Convert to pandas
def to_pandas(self):
"""Convert response data to pandas data frame.

Creates a pandas data frame for the value of the frequency
response at each `omega`. The frequency response values are
labeled in the form "H_{<out>, <in>}" where "<out>" and "<in>"
are replaced with the output and input labels for the system.

"""
if not pandas_check():
ImportError('pandas not installed')
import pandas
Expand Down
44 changes: 13 additions & 31 deletions control/freqplot.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,14 +19,14 @@

from . import config
from .bdalg import feedback
from .ctrlplot import suptitle, _find_axes_center
from .ctrlplot import suptitle, _find_axes_center, _make_legend_labels, \
_update_suptitle
from .ctrlutil import unwrap
from .exception import ControlMIMONotImplemented
from .frdata import FrequencyResponseData
from .lti import LTI, _process_frequency_response, frequency_response
from .margins import stability_margins
from .statesp import StateSpace
from .timeplot import _make_legend_labels
from .xferfcn import TransferFunction

__all__ = ['bode_plot', 'NyquistResponseData', 'nyquist_response',
Expand Down Expand Up @@ -954,28 +954,7 @@ def gen_zero_centered_series(val_min, val_max, period):
else:
title = data[0].title

if fig is not None and isinstance(title, str):
# Get the current title, if it exists
old_title = None if fig._suptitle is None else fig._suptitle._text
new_title = title

if old_title is not None:
# Find the common part of the titles
common_prefix = commonprefix([old_title, new_title])

# Back up to the last space
last_space = common_prefix.rfind(' ')
if last_space > 0:
common_prefix = common_prefix[:last_space]
common_len = len(common_prefix)

# Add the new part of the title (usually the system name)
if old_title[common_len:] != new_title[common_len:]:
separator = ',' if len(common_prefix) > 0 else ';'
new_title = old_title + separator + new_title[common_len:]

# Add the title
suptitle(title, fig=fig, rcParams=rcParams, frame=suptitle_frame)
_update_suptitle(fig, title, rcParams=rcParams, frame=suptitle_frame)

#
# Create legends
Expand Down Expand Up @@ -2668,12 +2647,13 @@ def _get_line_labels(ax, use_color=True):


# Turn label keyword into array indexed by trace, output, input
def _process_line_labels(label, nsys, ninputs=0, noutputs=0):
# TODO: move to ctrlutil.py and update parameter names to reflect general use
def _process_line_labels(label, ntraces, ninputs=0, noutputs=0):
if label is None:
return None

if isinstance(label, str):
label = [label]
label = [label] * ntraces # single label for all traces

# Convert to an ndarray, if not done aleady
try:
Expand All @@ -2685,12 +2665,14 @@ def _process_line_labels(label, nsys, ninputs=0, noutputs=0):
# TODO: allow more sophisticated broadcasting (and error checking)
try:
if ninputs > 0 and noutputs > 0:
if line_labels.ndim == 1:
line_labels = line_labels.reshape(nsys, 1, 1)
line_labels = np.broadcast_to(
line_labels,(nsys, ninputs, noutputs))
if line_labels.ndim == 1 and line_labels.size == ntraces:
line_labels = line_labels.reshape(ntraces, 1, 1)
line_labels = np.broadcast_to(
line_labels, (ntraces, ninputs, noutputs))
else:
line_labels = line_labels.reshape(ntraces, ninputs, noutputs)
except:
if line_labels.shape[0] != nsys:
if line_labels.shape[0] != ntraces:
raise ValueError("number of labels must match number of traces")
else:
raise ValueError("labels must be given for each input/output pair")
Expand Down
18 changes: 14 additions & 4 deletions control/nlsys.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,8 @@
from . import config
from .iosys import InputOutputSystem, _parse_spec, _process_iosys_keywords, \
_process_signal_list, common_timebase, isctime, isdtime
from .timeresp import TimeResponseData, _check_convert_array, \
_process_time_response
from .timeresp import _check_convert_array, _process_time_response, \
TimeResponseData, TimeResponseList

__all__ = ['NonlinearIOSystem', 'InterconnectedSystem', 'nlsys',
'input_output_response', 'find_eqpt', 'linearize',
Expand Down Expand Up @@ -1327,8 +1327,8 @@ def input_output_response(

Parameters
----------
sys : InputOutputSystem
Input/output system to simulate.
sys : NonlinearIOSystem or list of NonlinearIOSystem
I/O system(s) for which input/output response is simulated.

T : array-like
Time steps at which the input is defined; values must be evenly spaced.
Expand Down Expand Up @@ -1448,6 +1448,16 @@ def input_output_response(
if kwargs:
raise TypeError("unrecognized keyword(s): ", str(kwargs))

# If passed a list, recursively call individual responses with given T
if isinstance(sys, (list, tuple)):
sysdata, responses = sys, []
for sys in sysdata:
responses.append(input_output_response(
sys, T, U=U, X0=X0, params=params, transpose=transpose,
return_x=return_x, squeeze=squeeze, t_eval=t_eval,
solve_ivp_kwargs=solve_ivp_kwargs, **kwargs))
return TimeResponseList(responses)

# Sanity checking on the input
if not isinstance(sys, NonlinearIOSystem):
raise TypeError("System of type ", type(sys), " not valid")
Expand Down
1 change: 1 addition & 0 deletions control/tests/kwargs_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -308,6 +308,7 @@ def test_response_plot_kwargs(data_fcn, plot_fcn, mimo):
'StateSpace.sample': test_unrecognized_kwargs,
'TimeResponseData.__call__': trdata_test.test_response_copy,
'TimeResponseData.plot': timeplot_test.test_errors,
'TimeResponseList.plot': timeplot_test.test_errors,
'TransferFunction.__init__': test_unrecognized_kwargs,
'TransferFunction.sample': test_unrecognized_kwargs,
'optimal.OptimalControlProblem.__init__':
Expand Down
Loading
Loading
0