diff --git a/control/__init__.py b/control/__init__.py index 9781ab80e..120d16325 100644 --- a/control/__init__.py +++ b/control/__init__.py @@ -77,6 +77,10 @@ from .xferfcn import * from .frdata import * +# Time responses and plotting +from .timeresp import * +from .timeplot import * + from .bdalg import * from .delay import * from .descfcn import * @@ -91,7 +95,6 @@ from .rlocus import * from .statefbk import * from .stochsys import * -from .timeresp import * from .ctrlutil import * from .canonical import * from .robust import * diff --git a/control/config.py b/control/config.py index 987693c2d..1ed8b5dd5 100644 --- a/control/config.py +++ b/control/config.py @@ -135,6 +135,9 @@ def reset_defaults(): from .optimal import _optimal_defaults defaults.update(_optimal_defaults) + from .timeplot import _timeplot_defaults + defaults.update(_timeplot_defaults) + def _get_param(module, param, argval=None, defval=None, pop=False, last=False): """Return the default value for a configuration option. diff --git a/control/nlsys.py b/control/nlsys.py index c540decb6..f11d08a31 100644 --- a/control/nlsys.py +++ b/control/nlsys.py @@ -1488,6 +1488,7 @@ def ufun(t): return TimeResponseData( t_eval, y, None, u, issiso=sys.issiso(), output_labels=sys.output_labels, input_labels=sys.input_labels, + title="Input/output response for " + sys.name, sysname=sys.name, transpose=transpose, return_x=return_x, squeeze=squeeze) # Create a lambda function for the right hand side @@ -1567,7 +1568,8 @@ def ivp_rhs(t, x): return TimeResponseData( soln.t, y, soln.y, u, issiso=sys.issiso(), output_labels=sys.output_labels, input_labels=sys.input_labels, - state_labels=sys.state_labels, + state_labels=sys.state_labels, sysname=sys.name, + title="Input/output response for " + sys.name, transpose=transpose, return_x=return_x, squeeze=squeeze) diff --git a/control/tests/kwargs_test.py b/control/tests/kwargs_test.py index 1abc7547e..19f4bb627 100644 --- a/control/tests/kwargs_test.py +++ b/control/tests/kwargs_test.py @@ -26,6 +26,7 @@ import control.tests.statefbk_test as statefbk_test import control.tests.stochsys_test as stochsys_test import control.tests.trdata_test as trdata_test +import control.tests.timeplot_test as timeplot_test @pytest.mark.parametrize("module, prefix", [ (control, ""), (control.flatsys, "flatsys."), (control.optimal, "optimal.") @@ -74,7 +75,8 @@ def test_kwarg_search(module, prefix): # @parametrize messes up the check, but we know it is there pass - elif source and source.find('unrecognized keyword') < 0: + elif source and source.find('unrecognized keyword') < 0 and \ + source.find('unexpected keyword') < 0: warnings.warn( f"'unrecognized keyword' not found in unit test " f"for {name}") @@ -161,7 +163,21 @@ def test_matplotlib_kwargs(function, nsysargs, moreargs, kwargs, mplcleanup): function(*args, **kwargs, unknown=None) +@pytest.mark.parametrize( + "function", [control.time_response_plot, control.TimeResponseData.plot]) +def test_time_response_plot_kwargs(function): + # Create a system for testing + response = control.step_response(control.rss(4, 2, 2)) + + # Call the plotting function normally and make sure it works + function(response) + # Now add an unrecognized keyword and make sure there is an error + with pytest.raises(AttributeError, + match="(has no property|unexpected keyword)"): + function(response, unknown=None) + + # # List of all unit tests that check for unrecognized keywords # @@ -185,6 +201,7 @@ def test_matplotlib_kwargs(function, nsysargs, moreargs, kwargs, mplcleanup): 'gangof4_plot': test_matplotlib_kwargs, 'input_output_response': test_unrecognized_kwargs, 'interconnect': interconnect_test.test_interconnect_exceptions, + 'time_response_plot': timeplot_test.test_errors, 'linearize': test_unrecognized_kwargs, 'lqe': test_unrecognized_kwargs, 'lqr': test_unrecognized_kwargs, @@ -230,6 +247,7 @@ def test_matplotlib_kwargs(function, nsysargs, moreargs, kwargs, mplcleanup): 'StateSpace.__init__': test_unrecognized_kwargs, 'StateSpace.sample': test_unrecognized_kwargs, 'TimeResponseData.__call__': trdata_test.test_response_copy, + 'TimeResponseData.plot': timeplot_test.test_errors, 'TransferFunction.__init__': test_unrecognized_kwargs, 'TransferFunction.sample': test_unrecognized_kwargs, 'optimal.OptimalControlProblem.__init__': diff --git a/control/tests/timeplot_test.py b/control/tests/timeplot_test.py new file mode 100644 index 000000000..7cdde5c54 --- /dev/null +++ b/control/tests/timeplot_test.py @@ -0,0 +1,511 @@ +# timeplot_test.py - test out time response plots +# RMM, 23 Jun 2023 + +import pytest +import control as ct +import matplotlib as mpl +import matplotlib.pyplot as plt +import numpy as np + +from control.tests.conftest import slycotonly + +# Detailed test of (almost) all functionality +# +# The commented out rows lead to very long testing times => these should be +# used only for developmental testing and not day-to-day testing. +@pytest.mark.parametrize( + "sys", [ + # ct.rss(1, 1, 1, strictly_proper=True, name="rss"), + ct.nlsys( + lambda t, x, u, params: -x + u, None, + inputs=1, outputs=1, states=1, name="nlsys"), + # ct.rss(2, 1, 2, strictly_proper=True, name="rss"), + ct.rss(2, 2, 1, strictly_proper=True, name="rss"), + # ct.drss(2, 2, 2, name="drss"), + # ct.rss(2, 2, 3, strictly_proper=True, name="rss"), + ]) +# @pytest.mark.parametrize("transpose", [False, True]) +# @pytest.mark.parametrize("plot_inputs", [False, None, True, 'overlay']) +# @pytest.mark.parametrize("plot_outputs", [True, False]) +# @pytest.mark.parametrize("overlay_signals", [False, True]) +# @pytest.mark.parametrize("overlay_traces", [False, True]) +# @pytest.mark.parametrize("second_system", [False, True]) +# @pytest.mark.parametrize("fcn", [ +# ct.step_response, ct.impulse_response, ct.initial_response, +# ct.forced_response]) +@pytest.mark.parametrize( # combinatorial-style test (faster) + "fcn, pltinp, pltout, cmbsig, cmbtrc, trpose, secsys", + [(ct.step_response, False, True, False, False, False, False), + (ct.step_response, None, True, False, False, False, False), + (ct.step_response, True, True, False, False, False, False), + (ct.step_response, 'overlay', True, False, False, False, False), + (ct.step_response, 'overlay', True, True, False, False, False), + (ct.step_response, 'overlay', True, False, True, False, False), + (ct.step_response, 'overlay', True, False, False, True, False), + (ct.step_response, 'overlay', True, False, False, False, True), + (ct.step_response, False, False, False, False, False, False), + (ct.step_response, None, False, False, False, False, False), + (ct.step_response, 'overlay', False, False, False, False, False), + (ct.step_response, True, True, False, True, False, False), + (ct.step_response, True, True, False, False, False, True), + (ct.step_response, True, True, False, True, False, True), + (ct.step_response, True, True, True, False, True, True), + (ct.step_response, True, True, False, True, True, True), + (ct.impulse_response, False, True, True, False, False, False), + (ct.initial_response, None, True, False, False, False, False), + (ct.initial_response, False, True, False, False, False, False), + (ct.initial_response, True, True, False, False, False, False), + (ct.forced_response, True, True, False, False, False, False), + (ct.forced_response, None, True, False, False, False, False), + (ct.forced_response, False, True, False, False, False, False), + (ct.forced_response, True, True, True, False, False, False), + (ct.forced_response, True, True, True, True, False, False), + (ct.forced_response, True, True, True, True, True, False), + (ct.forced_response, True, True, True, True, True, True), + (ct.forced_response, 'overlay', True, True, True, False, True), + (ct.input_output_response, + True, True, False, False, False, False), + ]) + +def test_response_plots( + fcn, sys, pltinp, pltout, cmbsig, cmbtrc, + trpose, secsys, clear=True): + # Figure out the time range to use and check some special cases + if not isinstance(sys, ct.lti.LTI): + if fcn == ct.impulse_response: + pytest.skip("impulse response not implemented for nlsys") + + # Nonlinear systems require explicit time limits + T = 10 + timepts = np.linspace(0, T) + + elif isinstance(sys, ct.TransferFunction) and fcn == ct.initial_response: + pytest.skip("initial response not tested for tf") + + else: + # Linear systems figure things out on their own + T = None + timepts = np.linspace(0, 10) # for input_output_response + + # Save up the keyword arguments + kwargs = dict( + plot_inputs=pltinp, plot_outputs=pltout, transpose=trpose, + overlay_signals=cmbsig, overlay_traces=cmbtrc) + + # Create the response + if fcn is ct.input_output_response and \ + not isinstance(sys, ct.NonlinearIOSystem): + # Skip transfer functions and other non-state space systems + return None + if fcn in [ct.input_output_response, ct.forced_response]: + U = np.zeros((sys.ninputs, timepts.size)) + for i in range(sys.ninputs): + U[i] = np.cos(timepts * i + i) + args = [timepts, U] + + elif fcn == ct.initial_response: + args = [T, np.ones(sys.nstates)] # T, X0 + + elif not isinstance(sys, ct.lti.LTI): + args = [T] # nonlinear systems require final time + + else: # step, initial, impulse responses + args = [] + + # Create a new figure (in case previous one is of the same size) and plot + if not clear: + plt.figure() + response = fcn(sys, *args) + + # Look for cases where there are no data to plot + if not pltout and ( + pltinp is False or response.ninputs == 0 or + pltinp is None and response.plot_inputs is False): + with pytest.raises(ValueError, match=".* no data to plot"): + out = response.plot(**kwargs) + return None + elif not pltout and pltinp == 'overlay': + with pytest.raises(ValueError, match="can't overlay inputs"): + out = response.plot(**kwargs) + return None + elif pltinp in [True, 'overlay'] and response.ninputs == 0: + with pytest.raises(ValueError, match=".* but no inputs"): + out = response.plot(**kwargs) + return None + + out = response.plot(**kwargs) + + # Make sure all of the outputs are of the right type + nlines_plotted = 0 + for ax_lines in np.nditer(out, flags=["refs_ok"]): + for line in ax_lines.item(): + assert isinstance(line, mpl.lines.Line2D) + nlines_plotted += 1 + + # Make sure number of plots is correct + if pltinp is None: + if fcn in [ct.forced_response, ct.input_output_response]: + pltinp = True + else: + pltinp = False + ntraces = max(1, response.ntraces) + nlines_expected = (response.ninputs if pltinp else 0) * ntraces + \ + (response.noutputs if pltout else 0) * ntraces + assert nlines_plotted == nlines_expected + + # Save the old axes to compare later + old_axes = plt.gcf().get_axes() + + # Add additional data (and provide info in the title) + if secsys: + newsys = ct.rss( + sys.nstates, sys.noutputs, sys.ninputs, strictly_proper=True) + if fcn not in [ct.initial_response, ct.forced_response, + ct.input_output_response] and \ + isinstance(sys, ct.lti.LTI): + # Reuse the previously computed time to make plots look nicer + fcn(newsys, *args, T=response.time[-1]).plot(**kwargs) + else: + # Compute and plot new response (time is one of the arguments) + fcn(newsys, *args).plot(**kwargs) + + # Make sure we have the same axes + new_axes = plt.gcf().get_axes() + assert new_axes == old_axes + + # Make sure every axes has more than one line + for ax in new_axes: + assert len(ax.get_lines()) > 1 + + # Update the title so we can see what is going on + fig = out[0, 0][0].axes.figure + fig.suptitle( + fig._suptitle._text + + f" [{sys.noutputs}x{sys.ninputs}, cs={cmbsig}, " + f"ct={cmbtrc}, pi={pltinp}, tr={trpose}]", + fontsize='small') + + # Get rid of the figure to free up memory + if clear: + plt.clf() + + +def test_axes_setup(): + get_plot_axes = ct.timeplot.get_plot_axes + + sys_2x3 = ct.rss(4, 2, 3) + sys_2x3b = ct.rss(4, 2, 3) + sys_3x2 = ct.rss(4, 3, 2) + sys_3x1 = ct.rss(4, 3, 1) + + # Two plots of the same size leaves axes unchanged + out1 = ct.step_response(sys_2x3).plot() + out2 = ct.step_response(sys_2x3b).plot() + np.testing.assert_equal(get_plot_axes(out1), get_plot_axes(out2)) + plt.close() + + # Two plots of same net size leaves axes unchanged (unfortunately) + out1 = ct.step_response(sys_2x3).plot() + out2 = ct.step_response(sys_3x2).plot() + np.testing.assert_equal( + get_plot_axes(out1).reshape(-1), get_plot_axes(out2).reshape(-1)) + plt.close() + + # Plots of different shapes generate new plots + out1 = ct.step_response(sys_2x3).plot() + out2 = ct.step_response(sys_3x1).plot() + ax1_list = get_plot_axes(out1).reshape(-1).tolist() + ax2_list = get_plot_axes(out2).reshape(-1).tolist() + for ax in ax1_list: + assert ax not in ax2_list + plt.close() + + # Passing a list of axes preserves those axes + out1 = ct.step_response(sys_2x3).plot() + out2 = ct.step_response(sys_3x1).plot() + out3 = ct.step_response(sys_2x3b).plot(ax=get_plot_axes(out1)) + np.testing.assert_equal(get_plot_axes(out1), get_plot_axes(out3)) + plt.close() + + # Sending an axes array of the wrong size raises exception + with pytest.raises(ValueError, match="not the right shape"): + out = ct.step_response(sys_2x3).plot() + ct.step_response(sys_3x1).plot(ax=get_plot_axes(out)) + sys_2x3 = ct.rss(4, 2, 3) + sys_2x3b = ct.rss(4, 2, 3) + sys_3x2 = ct.rss(4, 3, 2) + sys_3x1 = ct.rss(4, 3, 1) + + +@slycotonly +def test_legend_map(): + sys_mimo = ct.tf2ss( + [[[1], [0.1]], [[0.2], [1]]], + [[[1, 0.6, 1], [1, 1, 1]], [[1, 0.4, 1], [1, 2, 1]]], name="MIMO") + response = ct.step_response(sys_mimo) + response.plot( + legend_map=np.array([['center', 'upper right'], + [None, 'center right']]), + plot_inputs=True, overlay_signals=True, transpose=True, + title='MIMO step response with custom legend placement') + + +def test_combine_time_responses(): + sys_mimo = ct.rss(4, 2, 2) + timepts = np.linspace(0, 10, 100) + + # Combine two response with ntrace = 0 + U = np.vstack([np.sin(timepts), np.cos(2*timepts)]) + resp1 = ct.input_output_response(sys_mimo, timepts, U) + + U = np.vstack([np.cos(2*timepts), np.sin(timepts)]) + resp2 = ct.input_output_response(sys_mimo, timepts, U) + + combresp1 = ct.combine_time_responses([resp1, resp2]) + assert combresp1.ntraces == 2 + np.testing.assert_equal(combresp1.y[:, 0, :], resp1.y) + np.testing.assert_equal(combresp1.y[:, 1, :], resp2.y) + + # Combine two responses with ntrace != 0 + resp3 = ct.step_response(sys_mimo, timepts) + resp4 = ct.step_response(sys_mimo, timepts) + combresp2 = ct.combine_time_responses([resp3, resp4]) + assert combresp2.ntraces == resp3.ntraces + resp4.ntraces + np.testing.assert_equal(combresp2.y[:, 0:2, :], resp3.y) + np.testing.assert_equal(combresp2.y[:, 2:4, :], resp4.y) + + # Mixture + combresp3 = ct.combine_time_responses([resp1, resp2, resp3]) + assert combresp3.ntraces == resp3.ntraces + resp4.ntraces + np.testing.assert_equal(combresp3.y[:, 0, :], resp1.y) + np.testing.assert_equal(combresp3.y[:, 1, :], resp2.y) + np.testing.assert_equal(combresp3.y[:, 2:4, :], resp3.y) + assert combresp3.trace_types == [None, None] + resp3.trace_types + assert combresp3.trace_labels == \ + [resp1.title, resp2.title] + resp3.trace_labels + + # Rename the traces + labels = ["T1", "T2", "T3", "T4"] + combresp4 = ct.combine_time_responses( + [resp1, resp2, resp3], trace_labels=labels) + assert combresp4.trace_labels == labels + + # Automatically generated trace label names and types + resp5 = ct.step_response(sys_mimo, timepts) + resp5.title = "test" + resp5.trace_labels = None + resp5.trace_types = None + combresp5 = ct.combine_time_responses([resp1, resp5]) + assert combresp5.trace_labels == [resp1.title] + \ + ["test, trace 0", "test, trace 1"] + assert combresp4.trace_types == [None, None, 'step', 'step'] + + with pytest.raises(ValueError, match="must have the same number"): + resp = ct.step_response(ct.rss(4, 2, 3), timepts) + combresp = ct.combine_time_responses([resp1, resp]) + + with pytest.raises(ValueError, match="trace labels does not match"): + combresp = ct.combine_time_responses( + [resp1, resp2], trace_labels=["T1", "T2", "T3"]) + + with pytest.raises(ValueError, match="must have the same time"): + resp = ct.step_response(ct.rss(4, 2, 3), timepts/2) + combresp6 = ct.combine_time_responses([resp1, resp]) + + +@slycotonly +def test_linestyles(): + # Check to make sure we can change line styles + sys_mimo = ct.tf2ss( + [[[1], [0.1]], [[0.2], [1]]], + [[[1, 0.6, 1], [1, 1, 1]], [[1, 0.4, 1], [1, 2, 1]]], name="MIMO") + out = ct.step_response(sys_mimo).plot('k--', plot_inputs=True) + for ax in np.nditer(out, flags=["refs_ok"]): + for line in ax.item(): + assert line.get_color() == 'k' + assert line.get_linestyle() == '--' + + out = ct.step_response(sys_mimo).plot( + plot_inputs='overlay', overlay_signals=True, overlay_traces=True, + output_props=[{'color': c} for c in ['blue', 'orange']], + input_props=[{'color': c} for c in ['red', 'green']], + trace_props=[{'linestyle': s} for s in ['-', '--']]) + + assert out.shape == (1, 1) + lines = out[0, 0] + assert lines[0].get_color() == 'blue' and lines[0].get_linestyle() == '-' + assert lines[1].get_color() == 'orange' and lines[1].get_linestyle() == '-' + assert lines[2].get_color() == 'red' and lines[2].get_linestyle() == '-' + assert lines[3].get_color() == 'green' and lines[3].get_linestyle() == '-' + assert lines[4].get_color() == 'blue' and lines[4].get_linestyle() == '--' + assert lines[5].get_color() == 'orange' and lines[5].get_linestyle() == '--' + assert lines[6].get_color() == 'red' and lines[6].get_linestyle() == '--' + assert lines[7].get_color() == 'green' and lines[7].get_linestyle() == '--' + + +def test_rcParams(): + sys = ct.rss(2, 2, 2) + + # Create new set of rcParams + my_rcParams = { + 'axes.labelsize': 10, + 'axes.titlesize': 10, + 'figure.titlesize': 12, + 'legend.fontsize': 10, + 'xtick.labelsize': 10, + 'ytick.labelsize': 10, + } + + # Generate a figure with the new rcParams + out = ct.step_response(sys).plot(rcParams=my_rcParams) + ax = out[0, 0][0].axes + fig = ax.figure + + # Check to make sure new settings were used + assert ax.xaxis.get_label().get_fontsize() == 10 + assert ax.yaxis.get_label().get_fontsize() == 10 + assert ax.title.get_fontsize() == 10 + assert ax.xaxis._get_tick_label_size('x') == 10 + assert ax.yaxis._get_tick_label_size('y') == 10 + assert fig._suptitle.get_fontsize() == 12 + +def test_relabel(): + sys1 = ct.rss(2, inputs='u', outputs='y') + sys2 = ct.rss(1, 1, 1) # uses default i/o labels + + # Generate a plot with specific labels + ct.step_response(sys1).plot() + + # Generate a new plot, which overwrites labels + out = ct.step_response(sys2).plot() + ax = ct.get_plot_axes(out) + assert ax[0, 0].get_ylabel() == 'y[0]' + + # Regenerate the first plot + plt.figure() + ct.step_response(sys1).plot() + + # Generate a new plt, without relabeling + out = ct.step_response(sys2).plot(relabel=False) + ax = ct.get_plot_axes(out) + assert ax[0, 0].get_ylabel() == 'y' + + +def test_errors(): + sys = ct.rss(2, 1, 1) + stepresp = ct.step_response(sys) + with pytest.raises(AttributeError, + match="(has no property|unexpected keyword)"): + stepresp.plot(unknown=None) + + with pytest.raises(AttributeError, + match="(has no property|unexpected keyword)"): + ct.time_response_plot(stepresp, unknown=None) + + with pytest.raises(ValueError, match="unrecognized value"): + stepresp.plot(plot_inputs='unknown') + + for kw in ['input_props', 'output_props', 'trace_props']: + propkw = {kw: {'color': 'green'}} + with pytest.warns(UserWarning, match="ignored since fmt string"): + out = stepresp.plot('k-', **propkw) + assert out[0, 0][0].get_color() == 'k' + +if __name__ == "__main__": + # + # Interactive mode: generate plots for manual viewing + # + # Running this script in python (or better ipython) will show a + # collection of figures that should all look OK on the screeen. + # + + # In interactive mode, turn on ipython interactive graphics + plt.ion() + + # Start by clearing existing figures + plt.close('all') + + # Define a set of systems to test + sys_siso = ct.tf2ss([1], [1, 2, 1], name="SISO") + sys_mimo = ct.tf2ss( + [[[1], [0.1]], [[0.2], [1]]], + [[[1, 0.6, 1], [1, 1, 1]], [[1, 0.4, 1], [1, 2, 1]]], name="MIMO") + + # Define and run a selected set of interesting tests + # def test_response_plots( + # fcn, sys, plot_inputs, plot_outputs, overlay_signals, + # overlay_traces, transpose, second_system, clear=True): + N, T, F = None, True, False + test_cases = [ + # response fcn system in out cs ct tr ss + (ct.step_response, sys_siso, N, T, F, F, F, F), # 1 + (ct.step_response, sys_siso, T, F, F, F, F, F), # 2 + (ct.step_response, sys_siso, T, T, F, F, F, T), # 3 + (ct.step_response, sys_siso, 'overlay', T, F, F, F, T), # 4 + (ct.step_response, sys_mimo, F, T, F, F, F, F), # 5 + (ct.step_response, sys_mimo, T, T, F, F, F, F), # 6 + (ct.step_response, sys_mimo, 'overlay', T, F, F, F, F), # 7 + (ct.step_response, sys_mimo, T, T, T, F, F, F), # 8 + (ct.step_response, sys_mimo, T, T, T, T, F, F), # 9 + (ct.step_response, sys_mimo, T, T, F, F, T, F), # 10 + (ct.step_response, sys_mimo, T, T, T, F, T, F), # 11 + (ct.step_response, sys_mimo, 'overlay', T, T, F, T, F), # 12 + (ct.forced_response, sys_mimo, N, T, T, F, T, F), # 13 + (ct.forced_response, sys_mimo, 'overlay', T, F, F, F, F), # 14 + ] + for args in test_cases: + test_response_plots(*args, clear=F) + + # + # Run a few more special cases to show off capabilities (and save some + # of them for use in the documentation). + # + + test_legend_map() # show ability to set legend location + + # Basic step response + plt.figure() + ct.step_response(sys_mimo).plot() + plt.savefig('timeplot-mimo_step-default.png') + + # Step response with plot_inputs, overlay_signals + plt.figure() + ct.step_response(sys_mimo).plot( + plot_inputs=True, overlay_signals=True, + title="Step response for 2x2 MIMO system " + + "[plot_inputs, overlay_signals]") + plt.savefig('timeplot-mimo_step-pi_cs.png') + + # Input/output response with overlaid inputs, legend_map + plt.figure() + timepts = np.linspace(0, 10, 100) + U = np.vstack([np.sin(timepts), np.cos(2*timepts)]) + ct.input_output_response(sys_mimo, timepts, U).plot( + plot_inputs='overlay', + legend_map=np.array([['lower right'], ['lower right']]), + title="I/O response for 2x2 MIMO system " + + "[plot_inputs='overlay', legend_map]") + plt.savefig('timeplot-mimo_ioresp-ov_lm.png') + + # Multi-trace plot, transpose + plt.figure() + U = np.vstack([np.sin(timepts), np.cos(2*timepts)]) + resp1 = ct.input_output_response(sys_mimo, timepts, U) + + U = np.vstack([np.cos(2*timepts), np.sin(timepts)]) + resp2 = ct.input_output_response(sys_mimo, timepts, U) + + ct.combine_time_responses( + [resp1, resp2], trace_labels=["Scenario #1", "Scenario #2"]).plot( + transpose=True, + title="I/O responses for 2x2 MIMO system, multiple traces " + "[transpose]") + plt.savefig('timeplot-mimo_ioresp-mt_tr.png') + + plt.figure() + out = ct.step_response(sys_mimo).plot( + plot_inputs='overlay', overlay_signals=True, overlay_traces=True, + output_props=[{'color': c} for c in ['blue', 'orange']], + input_props=[{'color': c} for c in ['red', 'green']], + trace_props=[{'linestyle': s} for s in ['-', '--']]) + plt.savefig('timeplot-mimo_step-linestyle.png') diff --git a/control/timeplot.py b/control/timeplot.py new file mode 100644 index 000000000..6409a6660 --- /dev/null +++ b/control/timeplot.py @@ -0,0 +1,808 @@ +# timeplot.py - time plotting functions +# RMM, 20 Jun 2023 +# +# This file contains routines for plotting out time responses. These +# functions can be called either as standalone functions or access from the +# TimeDataResponse class. +# +# Note: It might eventually make sense to put the functions here +# directly into timeresp.py. + +import numpy as np +import matplotlib as mpl +import matplotlib.pyplot as plt +from os.path import commonprefix +from warnings import warn + +from . import config + +__all__ = ['time_response_plot', 'combine_time_responses', 'get_plot_axes'] + +# Default font dictionary +_timeplot_rcParams = mpl.rcParams.copy() +_timeplot_rcParams.update({ + 'axes.labelsize': 'small', + 'axes.titlesize': 'small', + 'figure.titlesize': 'medium', + 'legend.fontsize': 'x-small', + 'xtick.labelsize': 'small', + 'ytick.labelsize': 'small', +}) + +# Default values for module parameter variables +_timeplot_defaults = { + 'timeplot.rcParams': _timeplot_rcParams, + 'timeplot.trace_props': [ + {'linestyle': s} for s in ['-', '--', ':', '-.']], + 'timeplot.output_props': [ + {'color': c} for c in [ + 'tab:blue', 'tab:orange', 'tab:green', 'tab:pink', 'tab:gray']], + 'timeplot.input_props': [ + {'color': c} for c in [ + 'tab:red', 'tab:purple', 'tab:brown', 'tab:olive', 'tab:cyan']], + 'timeplot.time_label': "Time [s]", +} + +# Plot the input/output response of a system +def time_response_plot( + data, *fmt, ax=None, plot_inputs=None, plot_outputs=True, + transpose=False, overlay_traces=False, overlay_signals=False, + legend_map=None, legend_loc=None, add_initial_zero=True, + trace_labels=None, title=None, relabel=True, **kwargs): + """Plot the time response of an input/output system. + + This function creates a standard set of plots for the input/output + response of a system, with the data provided via a `TimeResponseData` + object, which is the standard output for python-control simulation + functions. + + Parameters + ---------- + data : TimeResponseData + Data to be plotted. + ax : array of Axes + The matplotlib Axes to draw the figure on. If not specified, the + Axes for the current figure are used or, if there is no current + figure with the correct number and shape of Axes, a new figure is + created. The default shape of the array should be (noutputs + + ninputs, ntraces), but if `overlay_traces` is set to `True` then + only one row is needed and if `overlay_signals` is set to `True` + then only one or two columns are needed (depending on plot_inputs + and plot_outputs). + plot_inputs : bool or str, optional + Sets how and where to plot the inputs: + * False: don't plot the inputs + * None: use value from time response data (default) + * 'overlay`: plot inputs overlaid with outputs + * True: plot the inputs on their own axes + plot_outputs : bool, optional + If False, suppress plotting of the outputs. + overlay_traces : bool, optional + If set to True, combine all traces onto a single row instead of + plotting a separate row for each trace. + overlay_signals : bool, optional + If set to True, combine all input and output signals onto a single + plot (for each). + transpose : bool, optional + If transpose is False (default), signals are plotted from top to + bottom, starting with outputs (if plotted) and then inputs. + Multi-trace plots are stacked horizontally. If transpose is True, + signals are plotted from left to right, starting with the inputs + (if plotted) and then the outputs. Multi-trace responses are + stacked vertically. + *fmt : :func:`matplotlib.pyplot.plot` format string, optional + Passed to `matplotlib` as the format string for all lines in the plot. + **kwargs : :func:`matplotlib.pyplot.plot` keyword properties, optional + Additional keywords passed to `matplotlib` to specify line properties. + + Returns + ------- + out : array of list of Line2D + Array of Line2D objects for each line in the plot. The shape of + the array matches the subplots shape and the value of the array is a + list of Line2D objects in that subplot. + + Additional Parameters + --------------------- + add_initial_zero : bool + Add an initial point of zero at the first time point for all + inputs with type 'step'. Default is True. + input_props : array of dicts + List of line properties to use when plotting combined inputs. The + default values are set by config.defaults['timeplot.input_props']. + legend_map : array of str, option + Location of the legend for multi-trace plots. Specifies an array + of legend location strings matching the shape of the subplots, with + each entry being either None (for no legend) or a legend location + string (see :func:`~matplotlib.pyplot.legend`). + legend_loc : str + Location of the legend within the axes for which it appears. This + value is used if legend_map is None. + output_props : array of dicts + List of line properties to use when plotting combined outputs. The + default values are set by config.defaults['timeplot.output_props']. + relabel : bool, optional + By default, existing figures and axes are relabeled when new data + are added. If set to `False`, just plot new data on existing axes. + time_label : str, optional + Label to use for the time axis. + trace_props : array of dicts + List of line properties to use when plotting combined outputs. The + default values are set by config.defaults['timeplot.trace_props']. + + Notes + ----- + 1. A new figure will be generated if there is no current figure or + the current figure has an incompatible number of axes. To + force the creation of a new figures, use `plt.figure()`. To reuse + a portion of an existing figure, use the `ax` keyword. + + 2. The line properties (color, linestyle, etc) can be set for the + entire plot using the `fmt` and/or `kwargs` parameter, which + are passed on to `matplotlib`. When combining signals or + traces, the `input_props`, `output_props`, and `trace_props` + parameters can be used to pass a list of dictionaries + containing the line properties to use. These input/output + properties are combined with the trace properties and finally + the kwarg properties to determine the final line properties. + + 3. The default plot properties, such as font sizes, can be set using + config.defaults[''timeplot.rcParams']. + + """ + from .iosys import InputOutputSystem + from .timeresp import TimeResponseData + + # + # Process keywords and set defaults + # + + # Set up defaults + time_label = config._get_param( + 'timeplot', 'time_label', kwargs, _timeplot_defaults, pop=True) + timeplot_rcParams = config._get_param( + 'timeplot', 'rcParams', kwargs, _timeplot_defaults, pop=True) + + if kwargs.get('input_props', None) and len(fmt) > 0: + warn("input_props ignored since fmt string was present") + input_props = config._get_param( + 'timeplot', 'input_props', kwargs, _timeplot_defaults, pop=True) + iprop_len = len(input_props) + + if kwargs.get('output_props', None) and len(fmt) > 0: + warn("output_props ignored since fmt string was present") + output_props = config._get_param( + 'timeplot', 'output_props', kwargs, _timeplot_defaults, pop=True) + oprop_len = len(output_props) + + if kwargs.get('trace_props', None) and len(fmt) > 0: + warn("trace_props ignored since fmt string was present") + trace_props = config._get_param( + 'timeplot', 'trace_props', kwargs, _timeplot_defaults, pop=True) + tprop_len = len(trace_props) + + # Set the title for the data + title = data.title if title == None else title + + # Determine whether or not to plot the input data (and how) + if plot_inputs is None: + plot_inputs = data.plot_inputs + if plot_inputs not in [True, False, 'overlay']: + raise ValueError(f"unrecognized value: {plot_inputs=}") + + # + # Find/create axes + # + # Data are plotted in a standard subplots array, whose size depends on + # which signals are being plotted and how they are combined. The + # baseline layout for data is to plot everything separately, with + # outputs and inputs making up the rows and traces making up the + # columns: + # + # Trace 0 Trace q + # +------+ +------+ + # | y[0] | ... | y[0] | + # +------+ +------+ + # : + # +------+ +------+ + # | y[p] | ... | y[p] | + # +------+ +------+ + # + # +------+ +------+ + # | u[0] | ... | u[0] | + # +------+ +------+ + # : + # +------+ +------+ + # | u[m] | ... | u[m] | + # +------+ +------+ + # + # A variety of options are available to modify this format: + # + # * Omitting: either the inputs or the outputs can be omitted. + # + # * Combining: inputs, outputs, and traces can be combined onto a + # single set of axes using various keyword combinations + # (overlay_signals, overlay_traces, plot_inputs='overlay'). This + # basically collapses data along either the rows or columns, and a + # legend is generated. + # + # * Transpose: if the `transpose` keyword is True, then instead of + # plotting the data vertically (outputs over inputs), we plot left to + # right (inputs, outputs): + # + # +------+ +------+ +------+ +------+ + # Trace 0 | u[0] | ... | u[m] | | y[0] | ... | y[p] | + # +------+ +------+ +------+ +------+ + # : + # : + # +------+ +------+ +------+ +------+ + # Trace q | u[0] | ... | u[m] | | y[0] | ... | y[p] | + # +------+ +------+ +------+ +------+ + # + # This also affects the way in which legends and labels are generated. + + # Decide on the number of inputs and outputs + ninputs = data.ninputs if plot_inputs else 0 + noutputs = data.noutputs if plot_outputs else 0 + ntraces = max(1, data.ntraces) # treat data.ntraces == 0 as 1 trace + if ninputs == 0 and noutputs == 0: + raise ValueError( + "plot_inputs and plot_outputs both False; no data to plot") + elif plot_inputs == 'overlay' and noutputs == 0: + raise ValueError( + "can't overlay inputs with no outputs") + elif plot_inputs in [True, 'overlay'] and data.ninputs == 0: + raise ValueError( + "input plotting requested but no inputs in time response data") + + # Figure how how many rows and columns to use + offsets for inputs/outputs + if plot_inputs == 'overlay' and not overlay_signals: + nrows = max(ninputs, noutputs) # Plot inputs on top of outputs + noutput_axes = 0 # No offset required + ninput_axes = 0 # No offset required + elif overlay_signals: + nrows = int(plot_outputs) # Start with outputs + nrows += int(plot_inputs == True) # Add plot for inputs if needed + noutput_axes = 1 if plot_outputs and plot_inputs is True else 0 + ninput_axes = 1 if plot_inputs is True else 0 + else: + nrows = noutputs + ninputs # Plot inputs separately + noutput_axes = noutputs if plot_outputs else 0 + ninput_axes = ninputs if plot_inputs else 0 + + ncols = ntraces if not overlay_traces else 1 + if transpose: + nrows, ncols = ncols, nrows + + # See if we can use the current figure axes + fig = plt.gcf() # get current figure (or create new one) + if ax is None and plt.get_fignums(): + ax = fig.get_axes() + if len(ax) == nrows * ncols: + # Assume that the shape is right (no easy way to infer this) + ax = np.array(ax).reshape(nrows, ncols) + elif len(ax) != 0: + # Need to generate a new figure + fig, ax = plt.figure(), None + else: + # Blank figure, just need to recreate axes + ax = None + + # Create new axes, if needed, and customize them + if ax is None: + with plt.rc_context(timeplot_rcParams): + ax_array = fig.subplots(nrows, ncols, sharex=True, squeeze=False) + fig.set_tight_layout(True) + fig.align_labels() + + else: + # Make sure the axes are the right shape + if ax.shape != (nrows, ncols): + raise ValueError( + "specified axes are not the right shape; " + f"got {ax.shape} but expecting ({nrows}, {ncols})") + ax_array = ax + + # + # Map inputs/outputs and traces to axes + # + # This set of code takes care of all of the various options for how to + # plot the data. The arrays output_map and input_map are used to map + # the different signals that are plotted onto the axes created above. + # This code is complicated because it has to handle lots of different + # variations. + # + + # Create the map from trace, signal to axes, accounting for overlay_* + output_map = np.empty((noutputs, ntraces), dtype=tuple) + input_map = np.empty((ninputs, ntraces), dtype=tuple) + + for i in range(noutputs): + for j in range(ntraces): + signal_index = i if not overlay_signals else 0 + trace_index = j if not overlay_traces else 0 + if transpose: + output_map[i, j] = (trace_index, signal_index + ninput_axes) + else: + output_map[i, j] = (signal_index, trace_index) + + for i in range(ninputs): + for j in range(ntraces): + signal_index = noutput_axes + (i if not overlay_signals else 0) + trace_index = j if not overlay_traces else 0 + if transpose: + input_map[i, j] = (trace_index, signal_index - noutput_axes) + else: + input_map[i, j] = (signal_index, trace_index) + + # + # Plot the data + # + # The ax_output and ax_input arrays have the axes needed for making the + # plots. Labels are used on each axes for later creation of legends. + # The gneric labels if of the form: + # + # signal name, trace label, system name + # + # The signal name or tracel label can be omitted if they will appear on + # the axes title or ylabel. The system name is always included, since + # multiple calls to plot() will require a legend that distinguishes + # which system signals are plotted. The system name is stripped off + # later (in the legend-handling code) if it is not needed, but must be + # included here since a plot may be built up by multiple calls to plot(). + # + + # Reshape the inputs and outputs for uniform indexing + outputs = data.y.reshape(data.noutputs, ntraces, -1) + if data.u is None or not plot_inputs: + inputs = None + else: + inputs = data.u.reshape(data.ninputs, ntraces, -1) + + # Create a list of lines for the output + out = np.empty((nrows, ncols), dtype=object) + for i in range(nrows): + for j in range(ncols): + out[i, j] = [] # unique list in each element + + # Utility function for creating line label + def _make_line_label(signal_index, signal_labels, trace_index): + label = "" # start with an empty label + + # Add the signal name if it won't appear as an axes label + if overlay_signals or plot_inputs == 'overlay': + label += signal_labels[signal_index] + + # Add the trace label if this is a multi-trace figure + if overlay_traces and ntraces > 1 or trace_labels: + label += ", " if label != "" else "" + if trace_labels: + label += trace_labels[trace_index] + elif data.trace_labels: + label += data.trace_labels[trace_index] + else: + label += f"trace {trace_index}" + + # Add the system name (will strip off later if redundant) + label += ", " if label != "" else "" + label += f"{data.sysname}" + + return label + + # Go through each trace and each input/output + for trace in range(ntraces): + # Plot the output + for i in range(noutputs): + label = _make_line_label(i, data.output_labels, trace) + + # Set up line properties for this output, trace + if len(fmt) == 0: + line_props = output_props[ + i % oprop_len if overlay_signals else 0].copy() + line_props.update( + trace_props[trace % tprop_len if overlay_traces else 0]) + line_props.update(kwargs) + else: + line_props = kwargs + + out[output_map[i, trace]] += ax_array[output_map[i, trace]].plot( + data.time, outputs[i][trace], *fmt, label=label, **line_props) + + # Plot the input + for i in range(ninputs): + label = _make_line_label(i, data.input_labels, trace) + + if add_initial_zero and data.ntraces > i \ + and data.trace_types[i] == 'step': + x = np.hstack([np.array([data.time[0]]), data.time]) + y = np.hstack([np.array([0]), inputs[i][trace]]) + else: + x, y = data.time, inputs[i][trace] + + # Set up line properties for this output, trace + if len(fmt) == 0: + line_props = input_props[ + i % iprop_len if overlay_signals else 0].copy() + line_props.update( + trace_props[trace % tprop_len if overlay_traces else 0]) + line_props.update(kwargs) + else: + line_props = kwargs + + out[input_map[i, trace]] += ax_array[input_map[i, trace]].plot( + x, y, *fmt, label=label, **line_props) + + # Stop here if the user wants to control everything + if not relabel: + return out + + # + # Label the axes (including trace labels) + # + # Once the data are plotted, we label the axes. The horizontal axes is + # always time and this is labeled only on the bottom most column. The + # vertical axes can consist either of a single signal or a combination + # of signals (when overlay_signal is True or plot+inputs = 'overlay'. + # + # Traces are labeled at the top of the first row of plots (regular) or + # the left edge of rows (tranpose). + # + + # Time units on the bottom + for col in range(ncols): + ax_array[-1, col].set_xlabel(time_label) + + # Keep track of whether inputs are overlaid on outputs + overlaid = plot_inputs == 'overlay' + overlaid_title = "Inputs, Outputs" + + if transpose: # inputs on left, outputs on right + # Label the inputs + if overlay_signals and plot_inputs: + label = overlaid_title if overlaid else "Inputs" + for trace in range(ntraces): + ax_array[input_map[0, trace]].set_ylabel(label) + else: + for i in range(ninputs): + label = overlaid_title if overlaid else data.input_labels[i] + for trace in range(ntraces): + ax_array[input_map[i, trace]].set_ylabel(label) + + # Label the outputs + if overlay_signals and plot_outputs: + label = overlaid_title if overlaid else "Outputs" + for trace in range(ntraces): + ax_array[output_map[0, trace]].set_ylabel(label) + else: + for i in range(noutputs): + label = overlaid_title if overlaid else data.output_labels[i] + for trace in range(ntraces): + ax_array[output_map[i, trace]].set_ylabel(label) + + # Set the trace titles, if needed + if ntraces > 1 and not overlay_traces: + for trace in range(ntraces): + # Get the existing ylabel for left column + label = ax_array[trace, 0].get_ylabel() + + # Add on the trace title + if trace_labels: + label = trace_labels[trace] + "\n" + label + elif data.trace_labels: + label = data.trace_labels[trace] + "\n" + label + else: + label = f"Trace {trace}" + "\n" + label + + ax_array[trace, 0].set_ylabel(label) + + else: # regular plot (outputs over inputs) + # Set the trace titles, if needed + if ntraces > 1 and not overlay_traces: + for trace in range(ntraces): + if trace_labels: + label = trace_labels[trace] + elif data.trace_labels: + label = data.trace_labels[trace] + else: + label = f"Trace {trace}" + + with plt.rc_context(timeplot_rcParams): + ax_array[0, trace].set_title(label) + + # Label the outputs + if overlay_signals and plot_outputs: + ax_array[output_map[0, 0]].set_ylabel("Outputs") + else: + for i in range(noutputs): + ax_array[output_map[i, 0]].set_ylabel( + overlaid_title if overlaid else data.output_labels[i]) + + # Label the inputs + if overlay_signals and plot_inputs: + label = overlaid_title if overlaid else "Inputs" + ax_array[input_map[0, 0]].set_ylabel(label) + else: + for i in range(ninputs): + label = overlaid_title if overlaid else data.input_labels[i] + ax_array[input_map[i, 0]].set_ylabel(label) + + # + # Create legends + # + # Legends can be placed manually by passing a legend_map array that + # matches the shape of the suplots, with each item being a string + # indicating the location of the legend for that axes (or None for no + # legend). + # + # If no legend spec is passed, a minimal number of legends are used so + # that each line in each axis can be uniquely identified. The details + # depends on the various plotting parameters, but the general rule is + # to place legends in the top row and right column. + # + # Because plots can be built up by multiple calls to plot(), the legend + # strings are created from the line labels manually. Thus an initial + # call to plot() may not generate any legends (eg, if no signals are + # combined nor overlaid), but subsequent calls to plot() will need a + # legend for each different line (system). + # + + # Figure out where to put legends + if legend_map is None: + legend_map = np.full(ax_array.shape, None, dtype=object) + if legend_loc == None: + legend_loc = 'center right' + if transpose: + if (overlay_signals or plot_inputs == 'overlay') and overlay_traces: + # Put a legend in each plot for inputs and outputs + if plot_outputs is True: + legend_map[0, ninput_axes] = legend_loc + if plot_inputs is True: + legend_map[0, 0] = legend_loc + elif overlay_signals: + # Put a legend in rightmost input/output plot + if plot_inputs is True: + legend_map[0, 0] = legend_loc + if plot_outputs is True: + legend_map[0, ninput_axes] = legend_loc + elif plot_inputs == 'overlay': + # Put a legend on the top of each column + for i in range(ntraces): + legend_map[0, i] = legend_loc + elif overlay_traces: + # Put a legend topmost input/output plot + legend_map[0, -1] = legend_loc + else: + # Put legend in the upper right + legend_map[0, -1] = legend_loc + else: # regular layout + if (overlay_signals or plot_inputs == 'overlay') and overlay_traces: + # Put a legend in each plot for inputs and outputs + if plot_outputs is True: + legend_map[0, -1] = legend_loc + if plot_inputs is True: + legend_map[noutput_axes, -1] = legend_loc + elif overlay_signals: + # Put a legend in rightmost input/output plot + if plot_outputs is True: + legend_map[0, -1] = legend_loc + if plot_inputs is True: + legend_map[noutput_axes, -1] = legend_loc + elif plot_inputs == 'overlay': + # Put a legend on the right of each row + for i in range(max(ninputs, noutputs)): + legend_map[i, -1] = legend_loc + elif overlay_traces: + # Put a legend topmost input/output plot + legend_map[0, -1] = legend_loc + else: + # Put legend in the upper right + legend_map[0, -1] = legend_loc + + # Create axis legends + for i in range(nrows): + for j in range(ncols): + ax = ax_array[i, j] + # Get the labels to use + labels = [line.get_label() for line in ax.get_lines()] + + # Look for a common prefix (up to a space) + common_prefix = commonprefix(labels) + last_space = common_prefix.rfind(', ') + if last_space < 0 or plot_inputs == 'overlay': + common_prefix = '' + elif last_space > 0: + common_prefix = common_prefix[:last_space] + prefix_len = len(common_prefix) + + # Look for a common suffice (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: + labels = [label[prefix_len:-suffix_len] for label in labels] + else: + labels = [label[prefix_len:] for label in labels] + + # Update the labels to remove common strings + if len(labels) > 1 and legend_map[i, j] != None: + with plt.rc_context(timeplot_rcParams): + ax.legend(labels, loc=legend_map[i, j]) + + # + # Update the plot title (= figure suptitle) + # + # If plots are built up by multiple calls to plot() and the title is + # not given, then the title is updated to provide a list of unique text + # items in each successive title. For data generated by the I/O + # response functions this will generate a common prefix followed by a + # list of systems (e.g., "Step response for sys[1], sys[2]"). + # + + if fig is not None and title is not None: + # 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 + with plt.rc_context(timeplot_rcParams): + fig.suptitle(new_title) + + return out + + +def combine_time_responses(response_list, trace_labels=None, title=None): + """Combine multiple individual time responses into a multi-trace response. + + This function combines multiple instances of :class:`TimeResponseData` + into a multi-trace :class:`TimeResponseData` object. + + Parameters + ---------- + response_list : list of :class:`TimeResponseData` objects + Reponses to be combined. + trace_labels : list of str, optional + List of labels for each trace. If not specified, trace names are + taken from the input data or set to None. + + Returns + ------- + data : :class:`TimeResponseData` + Multi-trace input/output data. + + """ + from .timeresp import TimeResponseData + + # Save the first trace as the base case + base = response_list[0] + + # Process keywords + title = base.title if title is None else title + + # Figure out the size of the data (and check for consistency) + ntraces = max(1, base.ntraces) + + # Initial pass through trace list to count things up and do error checks + for response in response_list[1:]: + # Make sure the time vector is the same + if not np.allclose(base.t, response.t): + raise ValueError("all responses must have the same time vector") + + # Make sure the dimensions are all the same + if base.ninputs != response.ninputs or \ + base.noutputs != response.noutputs or \ + base.nstates != response.nstates: + raise ValueError("all responses must have the same number of " + "inputs, outputs, and states") + + ntraces += max(1, response.ntraces) + + # Create data structures for the new time response data object + inputs = np.empty((base.ninputs, ntraces, base.t.size)) + outputs = np.empty((base.noutputs, ntraces, base.t.size)) + states = np.empty((base.nstates, ntraces, base.t.size)) + + # See whether we should create labels or not + if trace_labels is None: + generate_trace_labels = True + trace_labels = [] + elif len(trace_labels) != ntraces: + raise ValueError( + "number of trace labels does not match number of traces") + else: + generate_trace_labels = False + + offset = 0 + trace_types = [] + for response in response_list: + if response.ntraces == 0: + # Single trace + inputs[:, offset, :] = response.u + outputs[:, offset, :] = response.y + states[:, offset, :] = response.x + offset += 1 + + # Add on trace label and trace type + if generate_trace_labels: + trace_labels.append(response.title) + trace_types.append( + None if response.trace_types is None else response.types[0]) + + else: + # Save the data + for i in range(response.ntraces): + inputs[:, offset, :] = response.u[:, i, :] + outputs[:, offset, :] = response.y[:, i, :] + states[:, offset, :] = response.x[:, i, :] + + # Save the trace labels + if generate_trace_labels: + if response.trace_labels is not None: + trace_labels.append(response.trace_labels[i]) + else: + trace_labels.append(response.title + f", trace {i}") + + offset += 1 + + # Save the trace types + if response.trace_types is not None: + trace_types += response.trace_types + else: + trace_types += [None] * response.ntraces + + return TimeResponseData( + base.t, outputs, states, inputs, issiso=base.issiso, + output_labels=base.output_labels, input_labels=base.input_labels, + state_labels=base.state_labels, title=title, transpose=base.transpose, + return_x=base.return_x, squeeze=base.squeeze, sysname=base.sysname, + trace_labels=trace_labels, trace_types=trace_types, + plot_inputs=base.plot_inputs) + + +# 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) diff --git a/control/timeresp.py b/control/timeresp.py index defdbdf4e..bc13ad0d1 100644 --- a/control/timeresp.py +++ b/control/timeresp.py @@ -81,6 +81,7 @@ from . import config from .exception import pandas_check from .iosys import isctime, isdtime +from .timeplot import time_response_plot __all__ = ['forced_response', 'step_response', 'step_info', @@ -169,10 +170,24 @@ class TimeResponseData: input_labels, output_labels, state_labels : array of str Names for the input, output, and state variables. - ntraces : int + sysname : str, optional + Name of the system that created the data. + + plot_inputs : bool, optional + Whether or not to plot the inputs by default (can be overridden in + the plot() method) + + ntraces : int, optional Number of independent traces represented in the input/output - response. If ntraces is 0 then the data represents a single trace - with the trace index surpressed in the data. + response. If ntraces is 0 (default) then the data represents a + single trace with the trace index surpressed in the data. + + trace_labels : array of string, optional + Labels to use for traces (set to sysname it ntraces is 0) + + trace_types : array of string, optional + Type of trace. Currently only 'step' is supported, which controls + the way in which the signal is plotted. Notes ----- @@ -210,7 +225,9 @@ class TimeResponseData: def __init__( self, time, outputs, states=None, inputs=None, issiso=None, output_labels=None, state_labels=None, input_labels=None, - transpose=False, return_x=False, squeeze=None, multi_trace=False + title=None, transpose=False, return_x=False, squeeze=None, + multi_trace=False, trace_labels=None, trace_types=None, + plot_inputs=True, sysname=None ): """Create an input/output time response object. @@ -241,9 +258,8 @@ def __init__( single-input, multi-trace response), or a 3D array indexed by input, trace, and time. - sys : LTI or InputOutputSystem, optional - System that generated the data. If desired, the system used to - generate the data can be stored along with the data. + title : str, optonal + Title of the data set (used as figure title in plotting). squeeze : bool, optional By default, if a system is single-input, single-output (SISO) @@ -267,6 +283,9 @@ def __init__( Optional labels for the inputs, outputs, and states, given as a list of strings matching the appropriate signal dimension. + sysname : str, optional + Name of the system that created the data. + transpose : bool, optional If True, transpose all input and output arrays (for backward compatibility with MATLAB and :func:`scipy.signal.lsim`). @@ -276,6 +295,10 @@ def __init__( If True, return the state vector when enumerating result by assigning to a tuple (default = False). + plot_inputs : bool, optional + Whether or not to plot the inputs by default (can be overridden + in the plot() method) + multi_trace : bool, optional If ``True``, then 2D input array represents multiple traces. For a MIMO system, the ``input`` attribute should then be set to @@ -290,6 +313,8 @@ def __init__( self.t = np.atleast_1d(time) if self.t.ndim != 1: raise ValueError("Time vector must be 1D array") + self.title = title + self.sysname = sysname # # Output vector (and number of traces) @@ -363,9 +388,11 @@ def __init__( if inputs is None: self.u = None self.ninputs = 0 + self.plot_inputs = False else: self.u = np.array(inputs) + self.plot_inputs = plot_inputs # Make sure the shape is OK and figure out the nuumber of inputs if multi_trace and self.u.ndim == 3 and \ @@ -397,6 +424,11 @@ def __init__( self.input_labels = _process_labels( input_labels, "input", self.ninputs) + # Check and store trace labels, if present + self.trace_labels = _process_labels( + trace_labels, "trace", self.ntraces) + self.trace_types = trace_types + # Figure out if the system is SISO if issiso is None: # Figure out based on the data @@ -655,6 +687,10 @@ def to_pandas(self): return pandas.DataFrame(data) + # Plot data + def plot(self, *args, **kwargs): + return time_response_plot(self, *args, **kwargs) + # Process signal labels def _process_labels(labels, signal, length): @@ -1132,7 +1168,8 @@ def forced_response(sys, T=None, U=0., X0=0., transpose=False, return TimeResponseData( tout, yout, xout, U, issiso=sys.issiso(), output_labels=sys.output_labels, input_labels=sys.input_labels, - state_labels=sys.state_labels, + state_labels=sys.state_labels, sysname=sys.name, plot_inputs=True, + title="Forced response for " + sys.name, trace_types=['forced'], transpose=transpose, return_x=return_x, squeeze=squeeze) @@ -1324,7 +1361,7 @@ def step_response(sys, T=None, X0=0, input=None, output=None, T_num=None, from .statesp import _convert_to_statespace # Create the time and input vectors - if T is None or np.asarray(T).size == 1 and isinstance(sys, LTI): + if T is None or np.asarray(T).size == 1: T = _default_time_vector(sys, N=T_num, tfinal=T, is_step=True) T = np.atleast_1d(T).reshape(-1) if T.ndim != 1 and len(T) < 2: @@ -1338,7 +1375,7 @@ def step_response(sys, T=None, X0=0, input=None, output=None, T_num=None, "with given X0.") # Convert to state space so that we can simulate - if sys.nstates is None: + if isinstance(sys, LTI) and sys.nstates is None: sys = _convert_to_statespace(sys) # Set up arrays to handle the output @@ -1349,11 +1386,16 @@ def step_response(sys, T=None, X0=0, input=None, output=None, T_num=None, uout = np.empty((ninputs, ninputs, T.size)) # Simulate the response for each input + trace_labels, trace_types = [], [] for i in range(sys.ninputs): # If input keyword was specified, only simulate for that input if isinstance(input, int) and i != input: continue + # Save a label and type for this plot + trace_labels.append(f"From {sys.input_labels[i]}") + trace_types.append('step') + # Create a set of single inputs system for simulation U = np.zeros((sys.ninputs, T.size)) U[i, :] = np.ones_like(T) @@ -1377,8 +1419,10 @@ def step_response(sys, T=None, X0=0, input=None, output=None, T_num=None, return TimeResponseData( response.time, yout, xout, uout, issiso=issiso, output_labels=output_labels, input_labels=input_labels, - state_labels=sys.state_labels, - transpose=transpose, return_x=return_x, squeeze=squeeze) + state_labels=sys.state_labels, title="Step response for " + sys.name, + transpose=transpose, return_x=return_x, squeeze=squeeze, + sysname=sys.name, trace_labels=trace_labels, + trace_types=trace_types, plot_inputs=False) def step_info(sysdata, T=None, T_num=None, yfinal=None, @@ -1695,10 +1739,7 @@ def initial_response(sys, T=None, X0=0, output=None, T_num=None, # Create the time and input vectors if T is None or np.asarray(T).size == 1: - if isinstance(sys, LTI): - T = _default_time_vector(sys, N=T_num, tfinal=T, is_step=False) - elif T_num is not None: - T = np.linspace(0, T, T_num) + T = _default_time_vector(sys, N=T_num, tfinal=T, is_step=False) T = np.atleast_1d(T).reshape(-1) if T.ndim != 1 and len(T) < 2: raise ValueError("invalid value of T for this type of system") @@ -1718,7 +1759,8 @@ def initial_response(sys, T=None, X0=0, output=None, T_num=None, return TimeResponseData( response.t, yout, response.x, None, issiso=issiso, output_labels=output_labels, input_labels=None, - state_labels=sys.state_labels, + state_labels=sys.state_labels, sysname=sys.name, + title="Initial response for " + sys.name, trace_types=['initial'], transpose=transpose, return_x=return_x, squeeze=squeeze) @@ -1819,7 +1861,7 @@ def impulse_response(sys, T=None, input=None, output=None, T_num=None, from .lti import LTI # Create the time and input vectors - if T is None or np.asarray(T).size == 1 and isinstance(sys, LTI): + if T is None or np.asarray(T).size == 1: T = _default_time_vector(sys, N=T_num, tfinal=T, is_step=False) T = np.atleast_1d(T).reshape(-1) if T.ndim != 1 and len(T) < 2: @@ -1845,11 +1887,16 @@ def impulse_response(sys, T=None, input=None, output=None, T_num=None, uout = np.full((ninputs, ninputs, np.asarray(T).size), None) # Simulate the response for each input + trace_labels, trace_types = [], [] for i in range(sys.ninputs): # If input keyword was specified, only handle that case if isinstance(input, int) and i != input: continue + # Save a label for this plot + trace_labels.append(f"From {sys.input_labels[i]}") + trace_types.append('impulse') + # # Compute new X0 that contains the impulse # @@ -1887,8 +1934,10 @@ def impulse_response(sys, T=None, input=None, output=None, T_num=None, return TimeResponseData( response.time, yout, xout, uout, issiso=issiso, output_labels=output_labels, input_labels=input_labels, - state_labels=sys.state_labels, - transpose=transpose, return_x=return_x, squeeze=squeeze) + state_labels=sys.state_labels, trace_labels=trace_labels, + trace_types=trace_types, title="Impulse response for " + sys.name, + sysname=sys.name, plot_inputs=False, transpose=transpose, + return_x=return_x, squeeze=squeeze) # utility function to find time period and time increment using pole locations @@ -2062,6 +2111,20 @@ def _ideal_tfinal_and_dt(sys, is_step=True): def _default_time_vector(sys, N=None, tfinal=None, is_step=True): """Returns a time vector that has a reasonable number of points. if system is discrete-time, N is ignored """ + from .lti import LTI + + # For non-LTI system, need tfinal + if not isinstance(sys, LTI): + if tfinal is None: + raise ValueError( + "can't automatically compute T for non-LTI system") + elif isinstance(tfinal, (int, float, np.number)): + if N is None: + return np.linspace(0, tfinal) + else: + return np.linspace(0, tfinal, N) + else: + return tfinal # Assume we got passed something appropriate N_max = 5000 N_min_ct = 100 # min points for cont time systems diff --git a/doc/Makefile b/doc/Makefile index 6e1012343..88a1b7bad 100644 --- a/doc/Makefile +++ b/doc/Makefile @@ -15,10 +15,13 @@ help: .PHONY: help Makefile # Rules to create figures -FIGS = classes.pdf +FIGS = classes.pdf timeplot-mimo_step-pi_cs.png classes.pdf: classes.fig fig2dev -Lpdf $< $@ +timeplot-mimo_step-pi_cs.png: ../control/tests/timeplot_test.py + PYTHONPATH=.. python $< + # Catch-all target: route all unknown targets to Sphinx using the new # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). html pdf clean doctest: Makefile $(FIGS) diff --git a/doc/index.rst b/doc/index.rst index 98b184286..ec556e7ce 100644 --- a/doc/index.rst +++ b/doc/index.rst @@ -26,6 +26,7 @@ implements basic operations for analysis and design of feedback control systems. conventions control classes + plotting matlab flatsys iosys diff --git a/doc/plotting.rst b/doc/plotting.rst new file mode 100644 index 000000000..d762a6755 --- /dev/null +++ b/doc/plotting.rst @@ -0,0 +1,142 @@ +.. _plotting-module: + +************* +Plotting data +************* + +The Python Control Toolbox contains a number of functions for plotting +input/output responses in the time and frequency domain, root locus +diagrams, and other standard charts used in control system analysis. While +some legacy functions do both analysis and plotting, the standard pattern +used in the toolbox is to provide a function that performs the basic +computation (e.g., time or frequency response) and returns and object +representing the output data. A separate plotting function, typically +ending in `_plot` is then used to plot the data. The plotting function is +also available via the `plot()` method of the analysis object, allowing the +following type of calls:: + + step_response(sys).plot() + frequency_response(sys).plot() # implementation pending + nyquist_curve(sys).plot() # implementation pending + rootlocus_curve(sys).plot() # implementation pending + +Time response data +================== + +Input/output time responses are produced one of several python-control +functions: :func:`~control.forced_response`, +:func:`~control.impulse_response`, :func:`~control.initial_response`, +:func:`~control.input_output_response`, :func:`~control.step_response`. +Each of these return a :class:`~control.TimeResponseData` object, which +contains the time, input, state, and output vectors associated with the +simulation. Time response data can be plotted with the +:func:`~control.time_response_plot` function, which is also available as +the :func:`~control.TimeResponseData.plot` method. For example, the step +response for a two-input, two-output can be plotted using the commands:: + + sys_mimo = ct.tf2ss( + [[[1], [0.1]], [[0.2], [1]]], + [[[1, 0.6, 1], [1, 1, 1]], [[1, 0.4, 1], [1, 2, 1]]], name="MIMO") + response = step_response(sys) + response.plot() + +which produces the following plot: + +.. image:: timeplot-mimo_step-default.png + +The :class:`~control.TimeResponseData` object can also be used to access +the data from the simulation:: + + time, outputs, inputs = response.time, response.outputs, response.inputs + fig, axs = plt.subplots(2, 2) + for i in range(2): + for j in range(2): + axs[i, j].plot(time, outputs[i, j]) + +A number of options are available in the `plot` method to customize +the appearance of input output data. For data produced by the +:func:`~control.impulse_response` and :func:`~control.step_response` +commands, the inputs are not shown. This behavior can be changed +using the `plot_inputs` keyword. It is also possible to combine +multiple lines onto a single graph, using either the `overlay_signals` +keyword (which puts all outputs out a single graph and all inputs on a +single graph) or the `overlay_traces` keyword, which puts different +traces (e.g., corresponding to step inputs in different channels) on +the same graph, with appropriate labeling via a legend on selected +axes. + +For example, using `plot_input=True` and `overlay_signals=True` yields the +following plot:: + + ct.step_response(sys_mimo).plot( + plot_inputs=True, overlay_signals=True, + title="Step response for 2x2 MIMO system " + + "[plot_inputs, overlay_signals]") + +.. image:: timeplot-mimo_step-pi_cs.png + +Input/output response plots created with either the +:func:`~control.forced_response` or the +:func:`~control.input_output_response` functions include the input signals by +default. These can be plotted on separate axes, but also "overlaid" on the +output axes (useful when the input and output signals are being compared to +each other). The following plot shows the use of `plot_inputs='overlay'` +as well as the ability to reposition the legends using the `legend_map` +keyword:: + + timepts = np.linspace(0, 10, 100) + U = np.vstack([np.sin(timepts), np.cos(2*timepts)]) + ct.input_output_response(sys_mimo, timepts, U).plot( + plot_inputs='overlay', + legend_map=np.array([['lower right'], ['lower right']]), + title="I/O response for 2x2 MIMO system " + + "[plot_inputs='overlay', legend_map]") + +.. image:: timeplot-mimo_ioresp-ov_lm.png + +Another option that is available is to use the `transpose` keyword so that +instead of plotting the outputs on the top and inputs on the bottom, the +inputs are plotted on the left and outputs on the right, as shown in the +following figure:: + + U1 = np.vstack([np.sin(timepts), np.cos(2*timepts)]) + resp1 = ct.input_output_response(sys_mimo, timepts, U1) + + U2 = np.vstack([np.cos(2*timepts), np.sin(timepts)]) + resp2 = ct.input_output_response(sys_mimo, timepts, U2) + + ct.combine_time_responses( + [resp1, resp2], trace_labels=["Scenario #1", "Scenario #2"]).plot( + transpose=True, + title="I/O responses for 2x2 MIMO system, multiple traces " + "[transpose]") + +.. image:: timeplot-mimo_ioresp-mt_tr.png + +This figure also illustrates the ability to create "multi-trace" plots +using the :func:`~control.combine_time_responses` function. The line +properties that are used when combining signals and traces are set by +the `input_props`, `output_props` and `trace_props` parameters for +:func:`~control.time_response_plot`. + +Additional customization is possible using the `input_props`, +`output_props`, and `trace_props` keywords to set complementary line colors +and styles for various signals and traces:: + + out = ct.step_response(sys_mimo).plot( + plot_inputs='overlay', overlay_signals=True, overlay_traces=True, + output_props=[{'color': c} for c in ['blue', 'orange']], + input_props=[{'color': c} for c in ['red', 'green']], + trace_props=[{'linestyle': s} for s in ['-', '--']]) + +.. image:: timeplot-mimo_step-linestyle.png + +Plotting functions +================== + +.. autosummary:: + :toctree: generated/ + + ~control.time_response_plot + ~control.combine_time_responses + ~control.get_plot_axes diff --git a/doc/timeplot-mimo_ioresp-mt_tr.png b/doc/timeplot-mimo_ioresp-mt_tr.png new file mode 100644 index 000000000..e4c800086 Binary files /dev/null and b/doc/timeplot-mimo_ioresp-mt_tr.png differ diff --git a/doc/timeplot-mimo_ioresp-ov_lm.png b/doc/timeplot-mimo_ioresp-ov_lm.png new file mode 100644 index 000000000..27dd89159 Binary files /dev/null and b/doc/timeplot-mimo_ioresp-ov_lm.png differ diff --git a/doc/timeplot-mimo_step-default.png b/doc/timeplot-mimo_step-default.png new file mode 100644 index 000000000..877764fbf Binary files /dev/null and b/doc/timeplot-mimo_step-default.png differ diff --git a/doc/timeplot-mimo_step-linestyle.png b/doc/timeplot-mimo_step-linestyle.png new file mode 100644 index 000000000..9685ea6fa Binary files /dev/null and b/doc/timeplot-mimo_step-linestyle.png differ diff --git a/doc/timeplot-mimo_step-pi_cs.png b/doc/timeplot-mimo_step-pi_cs.png new file mode 100644 index 000000000..6046c8cce Binary files /dev/null and b/doc/timeplot-mimo_step-pi_cs.png differ pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy