Repository: python-control/python-control

Issue:
Missing labels from forced_response output
Hi,

I wrote a state-space system representation with labels, like:

``` Python
sys = ct.ss(
  A, B, C, D,
  name="motor",
  states=("Ia", "Wm"),
  inputs=("Va", "Tl"),
  outputs=("Ia", "Wm", "Va", "Tl"),
)
```

But after simulating it, the result (a `TimeResponseData`), lacks information about the labels of the simulated system:
``` Python
T = np.linspace(0, 0.360, 360)
Va = np.ones_like(T) * 36
Tl = np.linspace(0, 15, len(T))

res = ct.forced_response(motor, T, U=[Va, Tl], X0=[0, 0])
print(res.output_labels)  # this is None
print(res.input_labels)  # this is None
print(res.state_labels)  # this is None
```

I wanted to use `.to_pandas()`, and after assigning it manually, it works, so it's probably just a matter of passing it forward internally in the code:
``` Python
res.output_labels = motor.output_labels
res.input_labels = motor.input_labels
res.state_labels = motor.state_labels
df = res.to_pandas().set_index("time")
```

For completeness, this is the error if I try to use `.to_pandass()` without the abovementioned workaround:

``` Python
    648 # Create a dict for setting up the data frame
    649 data = {'time': self.time}
    650 data.update(
--> 651     {name: self.u[i] for i, name in enumerate(self.input_labels)})
    652 data.update(
    653     {name: self.y[i] for i, name in enumerate(self.output_labels)})
    654 data.update(
    655     {name: self.x[i] for i, name in enumerate(self.state_labels)})

TypeError: 'NoneType' object is not iterable
```

Thanks

Discussion:
joaoantoniocardoso: Hi @murrayrm, I did the changes regarding using `_labels` instead of `_labels`, can you take a look again? Thanks!

Patch:
diff --git a/control/iosys.py b/control/iosys.py
index 78444f7c..dca00d3e 100644
--- a/control/iosys.py
+++ b/control/iosys.py
@@ -1862,7 +1862,7 @@ def input_output_response(
 
         return TimeResponseData(
             t_eval, y, None, u, issiso=sys.issiso(),
-            output_labels=sys.output_index, input_labels=sys.input_index,
+            output_labels=sys.output_labels, input_labels=sys.input_labels,
             transpose=transpose, return_x=return_x, squeeze=squeeze)
 
     # Create a lambda function for the right hand side
@@ -1941,8 +1941,8 @@ def input_output_response(
 
     return TimeResponseData(
         soln.t, y, soln.y, u, issiso=sys.issiso(),
-        output_labels=sys.output_index, input_labels=sys.input_index,
-        state_labels=sys.state_index,
+        output_labels=sys.output_labels, input_labels=sys.input_labels,
+        state_labels=sys.state_labels,
         transpose=transpose, return_x=return_x, squeeze=squeeze)
 
 
@@ -2881,7 +2881,7 @@ def interconnect(
 
             # Look for the signal name as a system input
             for sys in syslist:
-                if signal_name in sys.input_index.keys():
+                if signal_name in sys.input_labels:
                     connection.append(sign + sys.name + "." + signal_name)
 
             # Make sure we found the name
diff --git a/control/statesp.py b/control/statesp.py
index 41f92ae2..8661d874 100644
--- a/control/statesp.py
+++ b/control/statesp.py
@@ -1777,7 +1777,9 @@ def _mimo2siso(sys, input, output, warn_conversion=False):
         new_B = sys.B[:, input]
         new_C = sys.C[output, :]
         new_D = sys.D[output, input]
-        sys = StateSpace(sys.A, new_B, new_C, new_D, sys.dt)
+        sys = StateSpace(sys.A, new_B, new_C, new_D, sys.dt,
+                         name=sys.name,
+                         inputs=sys.input_labels[input], outputs=sys.output_labels[output])
 
     return sys
 
@@ -1826,7 +1828,9 @@ def _mimo2simo(sys, input, warn_conversion=False):
         #  Y = C*X + D*U
         new_B = sys.B[:, input:input+1]
         new_D = sys.D[:, input:input+1]
-        sys = StateSpace(sys.A, new_B, sys.C, new_D, sys.dt)
+        sys = StateSpace(sys.A, new_B, sys.C, new_D, sys.dt,
+                         name=sys.name,
+                         inputs=sys.input_labels[input], outputs=sys.output_labels)
 
     return sys
 
diff --git a/control/timeresp.py b/control/timeresp.py
index 638a0732..bd8595cf 100644
--- a/control/timeresp.py
+++ b/control/timeresp.py
@@ -694,7 +694,10 @@ def _process_labels(labels, signal, length):
             raise ValueError("Name dictionary for %s is incomplete" % signal)
 
     # Convert labels to a list
-    labels = list(labels)
+    if isinstance(labels, str):
+        labels = [labels]
+    else:
+        labels = list(labels)
 
     # Make sure the signal list is the right length and type
     if len(labels) != length:
@@ -1111,6 +1114,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,
         transpose=transpose, return_x=return_x, squeeze=squeeze)
 
 
@@ -1374,8 +1379,16 @@ def step_response(sys, T=None, X0=0., input=None, output=None, T_num=None,
     # Figure out if the system is SISO or not
     issiso = sys.issiso() or (input is not None and output is not None)
 
+    # Select only the given input and output, if any
+    input_labels = sys.input_labels if input is None \
+        else sys.input_labels[input]
+    output_labels = sys.output_labels if output is None \
+        else sys.output_labels[output]
+
     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)
 
 
@@ -1704,9 +1717,15 @@ def initial_response(sys, T=None, X0=0., input=0, output=None, T_num=None,
     # Figure out if the system is SISO or not
     issiso = sys.issiso() or (input is not None and output is not None)
 
+    # Select only the given output, if any
+    output_labels = sys.output_labels if output is None \
+        else sys.output_labels[0]
+
     # Store the response without an input
     return TimeResponseData(
         response.t, response.y, response.x, None, issiso=issiso,
+        output_labels=output_labels, input_labels=None,
+        state_labels=sys.state_labels,
         transpose=transpose, return_x=return_x, squeeze=squeeze)
 
 
@@ -1798,7 +1817,7 @@ def impulse_response(sys, T=None, X0=0., input=None, output=None, T_num=None,
     -----
     This function uses the `forced_response` function to compute the time
     response. For continuous time systems, the initial condition is altered to
-    account for the initial impulse. For discrete-time aystems, the impulse is 
+    account for the initial impulse. For discrete-time aystems, the impulse is
     sized so that it has unit area.
 
     Examples
@@ -1869,8 +1888,16 @@ def impulse_response(sys, T=None, X0=0., input=None, output=None, T_num=None,
     # Figure out if the system is SISO or not
     issiso = sys.issiso() or (input is not None and output is not None)
 
+    # Select only the given input and output, if any
+    input_labels = sys.input_labels if input is None \
+        else sys.input_labels[input]
+    output_labels = sys.output_labels if output is None \
+        else sys.output_labels[output]
+
     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)
 
 


Test patch:
diff --git a/control/tests/trdata_test.py b/control/tests/trdata_test.py
index 734d3559..028e5358 100644
--- a/control/tests/trdata_test.py
+++ b/control/tests/trdata_test.py
@@ -196,15 +196,20 @@ def test_response_copy():
     with pytest.raises(ValueError, match="not enough"):
         t, y, x = response_mimo
 
-    # Labels
-    assert response_mimo.output_labels is None
-    assert response_mimo.state_labels is None
-    assert response_mimo.input_labels is None
+    # Make sure labels are transferred to the response
+    assert response_siso.output_labels == sys_siso.output_labels
+    assert response_siso.state_labels == sys_siso.state_labels
+    assert response_siso.input_labels == sys_siso.input_labels
+    assert response_mimo.output_labels == sys_mimo.output_labels
+    assert response_mimo.state_labels == sys_mimo.state_labels
+    assert response_mimo.input_labels == sys_mimo.input_labels
+
+    # Check relabelling
     response = response_mimo(
         output_labels=['y1', 'y2'], input_labels='u',
-        state_labels=["x[%d]" % i for i in range(4)])
+        state_labels=["x%d" % i for i in range(4)])
     assert response.output_labels == ['y1', 'y2']
-    assert response.state_labels == ['x[0]', 'x[1]', 'x[2]', 'x[3]']
+    assert response.state_labels == ['x0', 'x1', 'x2', 'x3']
     assert response.input_labels == ['u']
 
     # Unknown keyword
@@ -231,6 +236,17 @@ def test_trdata_labels():
     np.testing.assert_equal(
         response.input_labels, ["u[%d]" % i for i in range(sys.ninputs)])
 
+    # Make sure the selected input and output are both correctly transferred to the response
+    for nu in range(sys.ninputs):
+        for ny in range(sys.noutputs):
+            step_response = ct.step_response(sys, T, input=nu, output=ny)
+            assert step_response.input_labels == [sys.input_labels[nu]]
+            assert step_response.output_labels == [sys.output_labels[ny]]
+
+            init_response = ct.initial_response(sys, T, input=nu, output=ny)
+            assert init_response.input_labels == None
+            assert init_response.output_labels == [sys.output_labels[ny]]
+
 
 def test_trdata_multitrace():
     #