Skip to content

Commit cd3d315

Browse files
committed
update index checking + docstrings
1 parent 944b6a2 commit cd3d315

8 files changed

Lines changed: 122 additions & 29 deletions

File tree

control/exception.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,10 @@ class ControlArgument(TypeError):
5252
"""Raised when arguments to a function are not correct"""
5353
pass
5454

55+
class ControlIndexError(IndexError):
56+
"""Raised when arguments to an indexed object are not correct"""
57+
pass
58+
5559
class ControlMIMONotImplemented(NotImplementedError):
5660
"""Function is not currently implemented for MIMO systems"""
5761
pass

control/frdata.py

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,20 @@ class constructor, using the :func:~~control.frd` factory function
9191
the imaginary access). See :meth:`~control.FrequencyResponseData.__call__`
9292
for a more detailed description.
9393
94+
A state space system is callable and returns the value of the transfer
95+
function evaluated at a point in the complex plane. See
96+
:meth:`~control.StateSpace.__call__` for a more detailed description.
97+
98+
Subsystem response corresponding to selected input/output pairs can be
99+
created by indexing the frequency response data object::
100+
101+
subsys = sys[output_spec, input_spec]
102+
103+
The input and output specifications can be single integers, lists of
104+
integers, or slices. In addition, the strings representing the names
105+
of the signals can be used and will be replaced with the equivalent
106+
signal offsets.
107+
94108
"""
95109
#
96110
# Class attributes
@@ -606,7 +620,7 @@ def __getitem__(self, key):
606620
# Convert signal names to integer offsets (via NamedSignal object)
607621
iomap = NamedSignal(
608622
self.fresp[:, :, 0], self.output_labels, self.input_labels)
609-
indices = iomap._parse_key(key)
623+
indices = iomap._parse_key(key, level=1) # ignore index checks
610624
outdx, outputs = _process_subsys_index(indices[0], self.output_labels)
611625
inpdx, inputs = _process_subsys_index(indices[1], self.input_labels)
612626

control/iosys.py

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
import numpy as np
1414

1515
from . import config
16+
from .exception import ControlIndexError
1617

1718
__all__ = ['InputOutputSystem', 'NamedSignal', 'issiso', 'timebase',
1819
'common_timebase', 'isdtime', 'isctime']
@@ -40,38 +41,55 @@ def __new__(cls, input_array, signal_labels=None, trace_labels=None):
4041
obj = np.asarray(input_array).view(cls) # Cast to our class type
4142
obj.signal_labels = signal_labels # Save signal labels
4243
obj.trace_labels = trace_labels # Save trace labels
44+
obj.data_shape = input_array.shape # Save data shape
4345
return obj # Return new object
4446

4547
def __array_finalize__(self, obj):
4648
# See https://numpy.org/doc/stable/user/basics.subclassing.html
4749
if obj is None: return
4850
self.signal_labels = getattr(obj, 'signal_labels', None)
4951
self.trace_labels = getattr(obj, 'trace_labels', None)
52+
self.data_shape = getattr(obj, 'data_shape', None)
5053

51-
def _parse_key(self, key, labels=None):
54+
def _parse_key(self, key, labels=None, level=0):
5255
if labels is None:
5356
labels = self.signal_labels
5457
try:
5558
if isinstance(key, str):
5659
key = labels.index(item := key)
60+
if level == 0 and len(self.data_shape) < 2:
61+
raise ControlIndexError
5762
elif isinstance(key, list):
5863
keylist = []
5964
for item in key: # use for loop to save item for error
60-
keylist.append(self._parse_key(item, labels=labels))
65+
keylist.append(
66+
self._parse_key(item, labels=labels, level=level+1))
67+
if level == 0 and key != keylist and len(self.data_shape) < 2:
68+
raise ControlIndexError
6169
key = keylist
6270
elif isinstance(key, tuple) and len(key) > 0:
6371
keylist = []
6472
keylist.append(
65-
self._parse_key(item := key[0], labels=self.signal_labels))
73+
self._parse_key(
74+
item := key[0], labels=self.signal_labels,
75+
level=level+1))
6676
if len(key) > 1:
6777
keylist.append(
6878
self._parse_key(
69-
item := key[1], labels=self.trace_labels))
79+
item := key[1], labels=self.trace_labels,
80+
level=level+1))
81+
if level == 0 and key[:len(keylist)] != tuple(keylist) \
82+
and len(keylist) > len(self.data_shape) - 1:
83+
raise ControlIndexError
7084
for i in range(2, len(key)):
7185
keylist.append(key[i]) # pass on remaining elements
7286
key = tuple(keylist)
7387
except ValueError:
7488
raise ValueError(f"unknown signal name '{item}'")
89+
except ControlIndexError:
90+
raise ControlIndexError(
91+
"signal name(s) not valid for squeezed data")
92+
7593
return key
7694

7795
def __getitem__(self, key):

control/statesp.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,17 @@ class StateSpace(NonlinearIOSystem, LTI):
153153
function evaluated at a point in the complex plane. See
154154
:meth:`~control.StateSpace.__call__` for a more detailed description.
155155
156+
Subsystems corresponding to selected input/output pairs can be
157+
created by indexing the state space system::
158+
159+
subsys = sys[output_spec, input_spec]
160+
161+
The input and output specifications can be single integers, lists of
162+
integers, or slices. In addition, the strings representing the names
163+
of the signals can be used and will be replaced with the equivalent
164+
signal offsets. The subsystem is created by truncating the inputs and
165+
outputs, but leaving the full set of system states.
166+
156167
StateSpace instances have support for IPython LaTeX output,
157168
intended for pretty-printing in Jupyter notebooks. The LaTeX
158169
output can be configured using
@@ -1221,7 +1232,7 @@ def __getitem__(self, key):
12211232

12221233
# Convert signal names to integer offsets
12231234
iomap = NamedSignal(self.D, self.output_labels, self.input_labels)
1224-
indices = iomap._parse_key(key)
1235+
indices = iomap._parse_key(key, level=1) # ignore index checks
12251236
outdx, output_labels = _process_subsys_index(
12261237
indices[0], self.output_labels)
12271238
inpdx, input_labels = _process_subsys_index(

control/tests/iosys_test.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2263,3 +2263,24 @@ def test_update_names():
22632263

22642264
with pytest.raises(TypeError, match=".* takes 1 positional argument"):
22652265
sys.update_names(5)
2266+
2267+
2268+
def test_signal_indexing():
2269+
# Response with two outputs, no traces
2270+
resp = ct.initial_response(ct.rss(4, 2, 1, strictly_proper=True))
2271+
assert resp.outputs['y[0]'].shape == resp.outputs.shape[1:]
2272+
assert resp.outputs[0, 0].item() == 0
2273+
2274+
# Implicitly squeezed response
2275+
resp = ct.step_response(ct.rss(4, 1, 1, strictly_proper=True))
2276+
for key in ['y[0]', ('y[0]', 'u[0]')]:
2277+
with pytest.raises(IndexError, match=r"signal name\(s\) not valid"):
2278+
resp.outputs.__getitem__(key)
2279+
2280+
# Explicitly squeezed response
2281+
resp = ct.step_response(
2282+
ct.rss(4, 2, 1, strictly_proper=True), squeeze=True)
2283+
assert resp.outputs['y[0]'].shape == resp.outputs.shape[1:]
2284+
with pytest.raises(IndexError, match=r"signal name\(s\) not valid"):
2285+
resp.outputs['y[0]', 'u[0]']
2286+

control/tests/timeresp_test.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1326,7 +1326,6 @@ def test_signal_labels():
13261326
response = ct.step_response(sys)
13271327

13281328
# Make sure access via strings works
1329-
np.testing.assert_equal(response.inputs['u[0]'], response.inputs[0])
13301329
np.testing.assert_equal(response.states['x[2]'], response.states[2])
13311330

13321331
# Make sure access via lists of strings works

control/timeresp.py

Lines changed: 37 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -204,34 +204,50 @@ class TimeResponseData:
204204
205205
Notes
206206
-----
207-
1. For backward compatibility with earlier versions of python-control,
208-
this class has an ``__iter__`` method that allows it to be assigned
209-
to a tuple with a variable number of elements. This allows the
210-
following patterns to work:
207+
The responses for individual elements of the time response can be
208+
accessed using integers, slices, or lists of signal offsets or the
209+
names of the appropriate signals::
211210
212-
t, y = step_response(sys)
213-
t, y, x = step_response(sys, return_x=True)
211+
sys = ct.rss(4, 2, 1)
212+
resp = ct.initial_response(sys, X0=[1, 1, 1, 1])
213+
plt.plot(resp.time, resp.outputs['y[0]'])
214214
215-
When using this (legacy) interface, the state vector is not affected by
216-
the `squeeze` parameter.
215+
In the case of multi-trace data, the responses should be indexed using
216+
the output signal name (or offset) and the input signal name (or
217+
offset)::
217218
218-
2. For backward compatibility with earlier version of python-control,
219-
this class has ``__getitem__`` and ``__len__`` methods that allow the
220-
return value to be indexed:
219+
sys = ct.rss(4, 2, 2, strictly_proper=True)
220+
resp = ct.step_response(sys)
221+
plt.plot(resp.time, resp.outputs[['y[0]', 'y[1]'], 'u[0]'].T)
221222
222-
response[0]: returns the time vector
223-
response[1]: returns the output vector
224-
response[2]: returns the state vector
223+
For backward compatibility with earlier versions of python-control,
224+
this class has an ``__iter__`` method that allows it to be assigned to
225+
a tuple with a variable number of elements. This allows the following
226+
patterns to work::
225227
226-
When using this (legacy) interface, the state vector is not affected by
227-
the `squeeze` parameter.
228+
t, y = step_response(sys)
229+
t, y, x = step_response(sys, return_x=True)
228230
229-
3. The default settings for ``return_x``, ``squeeze`` and ``transpose``
230-
can be changed by calling the class instance and passing new values:
231+
When using this (legacy) interface, the state vector is not affected
232+
by the `squeeze` parameter.
233+
234+
For backward compatibility with earlier version of python-control, this
235+
class has ``__getitem__`` and ``__len__`` methods that allow the return
236+
value to be indexed:
237+
238+
response[0]: returns the time vector
239+
response[1]: returns the output vector
240+
response[2]: returns the state vector
241+
242+
When using this (legacy) interface, the state vector is not affected
243+
by the `squeeze` parameter.
244+
245+
The default settings for ``return_x``, ``squeeze`` and ``transpose``
246+
can be changed by calling the class instance and passing new values::
231247
232248
response(tranpose=True).input
233249
234-
See :meth:`TimeResponseData.__call__` for more information.
250+
See :meth:`TimeResponseData.__call__` for more information.
235251
236252
"""
237253

@@ -1302,8 +1318,8 @@ def _process_time_response(
13021318
13031319
Returns
13041320
-------
1305-
output: ndarray
1306-
Processd signal. If the system is SISO and squeeze is not True,
1321+
output : ndarray
1322+
Processed signal. If the system is SISO and squeeze is not True,
13071323
the array is 1D (indexed by time). If the system is not SISO or
13081324
squeeze is False, the array is either 2D (indexed by output and
13091325
time) or 3D (indexed by input, output, and time).

control/xferfcn.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,16 @@ class TransferFunction(LTI):
147147
function evaluated at a point in the complex plane. See
148148
:meth:`~control.TransferFunction.__call__` for a more detailed description.
149149
150+
Subsystems corresponding to selected input/output pairs can be
151+
created by indexing the transfer function::
152+
153+
subsys = sys[output_spec, input_spec]
154+
155+
The input and output specifications can be single integers, lists of
156+
integers, or slices. In addition, the strings representing the names
157+
of the signals can be used and will be replaced with the equivalent
158+
signal offsets.
159+
150160
The TransferFunction class defines two constants ``s`` and ``z`` that
151161
represent the differentiation and delay operators in continuous and
152162
discrete time. These can be used to create variables that allow algebraic
@@ -769,7 +779,7 @@ def __getitem__(self, key):
769779
iomap = NamedSignal(
770780
np.empty((self.noutputs, self.ninputs)),
771781
self.output_labels, self.input_labels)
772-
indices = iomap._parse_key(key)
782+
indices = iomap._parse_key(key, level=1) # ignore index checks
773783
outdx, outputs = _process_subsys_index(
774784
indices[0], self.output_labels, slice_to_list=True)
775785
inpdx, inputs = _process_subsys_index(

0 commit comments

Comments
 (0)