Skip to content

Commit 8b416eb

Browse files
committed
Add list of systems functionality to time response functions
1 parent 44123c9 commit 8b416eb

9 files changed

Lines changed: 268 additions & 127 deletions

File tree

control/ctrlplot.py

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -125,11 +125,10 @@ def _update_suptitle(fig, title, rcParams=None, frame='axes'):
125125
if fig is not None and isinstance(title, str):
126126
# Get the current title, if it exists
127127
old_title = None if fig._suptitle is None else fig._suptitle._text
128-
new_title = title
129128

130129
if old_title is not None:
131130
# Find the common part of the titles
132-
common_prefix = commonprefix([old_title, new_title])
131+
common_prefix = commonprefix([old_title, title])
133132

134133
# Back up to the last space
135134
last_space = common_prefix.rfind(' ')
@@ -138,9 +137,9 @@ def _update_suptitle(fig, title, rcParams=None, frame='axes'):
138137
common_len = len(common_prefix)
139138

140139
# Add the new part of the title (usually the system name)
141-
if old_title[common_len:] != new_title[common_len:]:
140+
if old_title[common_len:] != title[common_len:]:
142141
separator = ',' if len(common_prefix) > 0 else ';'
143-
new_title = old_title + separator + new_title[common_len:]
142+
title = old_title + separator + title[common_len:]
144143

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

control/frdata.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -653,6 +653,14 @@ def plot(self, plot_type=None, *args, **kwargs):
653653

654654
# Convert to pandas
655655
def to_pandas(self):
656+
"""Convert response data to pandas data frame.
657+
658+
Creates a pandas data frame for the value of the frequency
659+
response at each `omega`. The frequency response values are
660+
labeled in the form "H_{<out>, <in>}" where "<out>" and "<in>"
661+
are replaced with the output and input labels for the system.
662+
663+
"""
656664
if not pandas_check():
657665
ImportError('pandas not installed')
658666
import pandas

control/nlsys.py

Lines changed: 13 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -27,8 +27,8 @@
2727
from . import config
2828
from .iosys import InputOutputSystem, _parse_spec, _process_iosys_keywords, \
2929
_process_signal_list, common_timebase, isctime, isdtime
30-
from .timeresp import TimeResponseData, _check_convert_array, \
31-
_process_time_response
30+
from .timeresp import _check_convert_array, _process_time_response, \
31+
TimeResponseData, TimeResponseList
3232

3333
__all__ = ['NonlinearIOSystem', 'InterconnectedSystem', 'nlsys',
3434
'input_output_response', 'find_eqpt', 'linearize',
@@ -1448,14 +1448,17 @@ def input_output_response(
14481448
if kwargs:
14491449
raise TypeError("unrecognized keyword(s): ", str(kwargs))
14501450

1451-
# Convert the first argument to a list
1452-
syslist = sysdata if isinstance(sysdata, (list, tuple)) else [sysdata]
1453-
1454-
# TODO: implement step responses for multiple systems
1455-
if len(syslist) > 1:
1456-
raise NotImplementedError(
1457-
"step responses for multiple systems not yet implemented")
1458-
sys = syslist[0]
1451+
# If passed a list, recursively call individual responses with given T
1452+
if isinstance(sysdata, (list, tuple)):
1453+
responses = []
1454+
for sys in sysdata:
1455+
responses.append(input_output_response(
1456+
sys, T, U=U, X0=X0, params=params, transpose=transpose,
1457+
return_x=return_x, squeeze=squeeze, t_eval=t_eval,
1458+
solve_ivp_kwargs=solve_ivp_kwargs, **kwargs))
1459+
return TimeResponseList(responses)
1460+
else:
1461+
sys = sysdata
14591462

14601463
# Sanity checking on the input
14611464
if not isinstance(sys, NonlinearIOSystem):

control/tests/kwargs_test.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -308,6 +308,7 @@ def test_response_plot_kwargs(data_fcn, plot_fcn, mimo):
308308
'StateSpace.sample': test_unrecognized_kwargs,
309309
'TimeResponseData.__call__': trdata_test.test_response_copy,
310310
'TimeResponseData.plot': timeplot_test.test_errors,
311+
'TimeResponseList.plot': timeplot_test.test_errors,
311312
'TransferFunction.__init__': test_unrecognized_kwargs,
312313
'TransferFunction.sample': test_unrecognized_kwargs,
313314
'optimal.OptimalControlProblem.__init__':

control/tests/timeplot_test.py

Lines changed: 76 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -313,14 +313,52 @@ def test_combine_time_responses():
313313
combresp6 = ct.combine_time_responses([resp1, resp])
314314

315315

316-
@pytest.mark.xfail(
317-
reason="step responses for multiple systems not yet implemented")
318-
def test_list_responses():
319-
sys1 = ct.rss(2, 2, 2)
320-
sys2 = ct.rss(2, 2, 2)
316+
@pytest.mark.parametrize("resp_fcn", [
317+
ct.step_response, ct.initial_response, ct.impulse_response,
318+
ct.forced_response, ct.input_output_response])
319+
def test_list_responses(resp_fcn):
320+
sys1 = ct.rss(2, 2, 2, strictly_proper=True)
321+
sys2 = ct.rss(2, 2, 2, strictly_proper=True)
322+
323+
# Figure out the expected shape of the system
324+
match resp_fcn:
325+
case ct.step_response | ct.impulse_response:
326+
shape = (2, 2)
327+
kwargs = {}
328+
case ct.initial_response:
329+
shape = (2, 1)
330+
kwargs = {}
331+
case ct.forced_response | ct.input_output_response:
332+
shape = (4, 1) # outputs and inputs both plotted
333+
T = np.linspace(0, 10)
334+
U = [np.sin(T), np.cos(T)]
335+
kwargs = {'T': T, 'U': U}
336+
337+
resp1 = resp_fcn(sys1, **kwargs)
338+
resp2 = resp_fcn(sys2, **kwargs)
339+
340+
# Sequential plotting results in colors rotating
341+
plt.figure()
342+
out1 = resp1.plot()
343+
out2 = resp2.plot()
344+
assert out1.shape == shape
345+
assert out2.shape == shape
346+
for row in range(2): # just look at the outputs
347+
for col in range(shape[1]):
348+
assert out1[row, col][0].get_color() == 'tab:blue'
349+
assert out2[row, col][0].get_color() == 'tab:orange'
321350

322-
resp = ct.step_response([sys1, sys2]).plot()
323-
assert resp.ntraces == 2
351+
plt.figure()
352+
resp_combined = resp_fcn([sys1, sys2], **kwargs)
353+
assert isinstance(resp_combined, ct.timeresp.TimeResponseList)
354+
assert resp_combined[0].time[-1] == max(resp1.time[-1], resp2.time[-1])
355+
assert resp_combined[1].time[-1] == max(resp1.time[-1], resp2.time[-1])
356+
out = resp_combined.plot()
357+
assert out.shape == shape
358+
for row in range(2): # just look at the outputs
359+
for col in range(shape[1]):
360+
assert out[row, col][0].get_color() == 'tab:blue'
361+
assert out[row, col][1].get_color() == 'tab:orange'
324362

325363

326364
@slycotonly
@@ -421,6 +459,12 @@ def test_errors():
421459
out = stepresp.plot('k-', **propkw)
422460
assert out[0, 0][0].get_color() == 'k'
423461

462+
# Make sure TimeResponseLists also work
463+
stepresp = ct.step_response([sys, sys])
464+
with pytest.raises(AttributeError,
465+
match="(has no property|unexpected keyword)"):
466+
stepresp.plot(unknown=None)
467+
424468
if __name__ == "__main__":
425469
#
426470
# Interactive mode: generate plots for manual viewing
@@ -519,3 +563,28 @@ def test_errors():
519563
input_props=[{'color': c} for c in ['red', 'green']],
520564
trace_props=[{'linestyle': s} for s in ['-', '--']])
521565
plt.savefig('timeplot-mimo_step-linestyle.png')
566+
567+
sys1 = ct.rss(4, 2, 2)
568+
sys2 = ct.rss(4, 2, 2)
569+
resp_list = ct.step_response([sys1, sys2])
570+
571+
fig = plt.figure()
572+
ct.combine_time_responses(
573+
[ct.step_response(sys1, resp_list[0].time),
574+
ct.step_response(sys2, resp_list[1].time)]
575+
).plot(overlay_traces=True)
576+
ct.suptitle("[Combine] " + fig._suptitle._text)
577+
578+
fig = plt.figure()
579+
ct.step_response(sys1).plot()
580+
ct.step_response(sys2).plot()
581+
ct.suptitle("[Sequential] " + fig._suptitle._text)
582+
583+
fig = plt.figure()
584+
ct.step_response(sys1).plot(color='b')
585+
ct.step_response(sys2).plot(color='r')
586+
ct.suptitle("[Seq w/color] " + fig._suptitle._text)
587+
588+
fig = plt.figure()
589+
ct.step_response([sys1, sys2]).plot()
590+
ct.suptitle("[List] " + fig._suptitle._text)

control/timeplot.py

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@
4444
'timeplot.time_label': "Time [s]",
4545
}
4646

47+
4748
# Plot the input/output response of a system
4849
def time_response_plot(
4950
data, *fmt, ax=None, plot_inputs=None, plot_outputs=True,
@@ -364,6 +365,16 @@ def _make_line_label(signal_index, signal_labels, trace_index):
364365

365366
return label
366367

368+
#
369+
# Store the color offsets with the figure to allow color/style cycling
370+
#
371+
# To allow repeated calls to time_response_plot() to cycle through
372+
# colors, we store an offset in the figure object that we can
373+
# retrieve at a later date, if needed.
374+
#
375+
output_offset = fig._output_offset = getattr(fig, '_output_offset', 0)
376+
input_offset = fig._input_offset = getattr(fig, '_input_offset', 0)
377+
367378
# Go through each trace and each input/output
368379
for trace in range(ntraces):
369380
# Plot the output
@@ -373,7 +384,8 @@ def _make_line_label(signal_index, signal_labels, trace_index):
373384
# Set up line properties for this output, trace
374385
if len(fmt) == 0:
375386
line_props = output_props[
376-
i % oprop_len if overlay_signals else 0].copy()
387+
(i + output_offset) % oprop_len if overlay_signals
388+
else output_offset].copy()
377389
line_props.update(
378390
trace_props[trace % tprop_len if overlay_traces else 0])
379391
line_props.update(kwargs)
@@ -397,7 +409,8 @@ def _make_line_label(signal_index, signal_labels, trace_index):
397409
# Set up line properties for this output, trace
398410
if len(fmt) == 0:
399411
line_props = input_props[
400-
i % iprop_len if overlay_signals else 0].copy()
412+
(i + input_offset) % iprop_len if overlay_signals
413+
else input_offset].copy()
401414
line_props.update(
402415
trace_props[trace % tprop_len if overlay_traces else 0])
403416
line_props.update(kwargs)
@@ -407,6 +420,12 @@ def _make_line_label(signal_index, signal_labels, trace_index):
407420
out[input_map[i, trace]] += ax_array[input_map[i, trace]].plot(
408421
x, y, *fmt, label=label, **line_props)
409422

423+
# Update the offsets so that we start at a new color/style the next time
424+
fig._output_offset = (
425+
output_offset + (noutputs if overlay_signals else 1)) % oprop_len
426+
fig._input_offset = (
427+
input_offset + (ninputs if overlay_signals else 1)) % iprop_len
428+
410429
# Stop here if the user wants to control everything
411430
if not relabel:
412431
return out

0 commit comments

Comments
 (0)