From 740d00bfd5065f18a39bbf4ff7fac0ce7f03168c Mon Sep 17 00:00:00 2001 From: marko1olo Date: Sun, 7 Jun 2026 19:26:55 +0400 Subject: [PATCH 1/2] fix: preserve signal names in scalar LTI ops --- control/statesp.py | 15 ++++++++++++--- control/tests/namedio_test.py | 34 ++++++++++++++++++++++++++++++++++ control/xferfcn.py | 22 +++++++++++++++++----- 3 files changed, 63 insertions(+), 8 deletions(-) diff --git a/control/statesp.py b/control/statesp.py index 8091e29ed..528db4a3d 100644 --- a/control/statesp.py +++ b/control/statesp.py @@ -555,7 +555,10 @@ def fmt_matrix(matrix, name): # Negation of a system def __neg__(self): """Negate a state space system.""" - return StateSpace(self.A, self.B, -self.C, -self.D, self.dt) + return StateSpace( + self.A, self.B, -self.C, -self.D, self.dt, + inputs=self.input_labels, outputs=self.output_labels, + states=self.state_labels) # Addition of two state space systems (parallel interconnection) def __add__(self, other): @@ -645,7 +648,10 @@ def __mul__(self, other): A, C = self.A, self.C B = self.B * other D = self.D * other - dt = self.dt + return StateSpace( + A, B, C, D, self.dt, + inputs=self.input_labels, outputs=self.output_labels, + states=self.state_labels) elif isinstance(other, np.ndarray): other = np.atleast_2d(other) @@ -706,7 +712,10 @@ def __rmul__(self, other): # Just multiplying by a scalar; change the input B = other * self.B D = other * self.D - return StateSpace(self.A, B, self.C, D, self.dt) + return StateSpace( + self.A, B, self.C, D, self.dt, + inputs=self.input_labels, outputs=self.output_labels, + states=self.state_labels) elif isinstance(other, np.ndarray): other = np.atleast_2d(other) diff --git a/control/tests/namedio_test.py b/control/tests/namedio_test.py index 8c44f5980..119caacf8 100644 --- a/control/tests/namedio_test.py +++ b/control/tests/namedio_test.py @@ -59,6 +59,40 @@ def test_named_ss(): " ['y1', 'y2']>" +@pytest.mark.parametrize( + "sys", + [ + ct.ss( + [[-1, 0], [0, -2]], [[1, 0], [0, 1]], + [[1, 0], [0, 1]], [[0, 0], [0, 0]], + inputs=['e1', 'e2'], outputs=['u1', 'u2'], + states=['x1', 'x2'], name='plant'), + ct.tf( + [[[1], [2]], [[3], [4]]], + [[[1, 1], [1, 2]], [[1, 3], [1, 4]]], + inputs=['e1', 'e2'], outputs=['u1', 'u2'], name='plant'), + ], +) +@pytest.mark.parametrize( + "operation", + [ + lambda sys: -sys, + lambda sys: sys * 2, + lambda sys: 2 * sys, + lambda sys: ct.negate(sys), + lambda sys: ct.series(sys, 2), + lambda sys: ct.series(2, sys), + ], +) +def test_named_scalar_operations_preserve_signal_names(sys, operation): + result = operation(sys) + + assert result.input_labels == sys.input_labels + assert result.output_labels == sys.output_labels + if isinstance(sys, ct.StateSpace): + assert result.state_labels == sys.state_labels + + # List of classes that are expected fun_instance = { ct.rss: (ct.NonlinearIOSystem, ct.StateSpace, ct.StateSpace), diff --git a/control/xferfcn.py b/control/xferfcn.py index ad9ea0554..f6c62430d 100644 --- a/control/xferfcn.py +++ b/control/xferfcn.py @@ -554,7 +554,9 @@ def __neg__(self): for i in range(self.noutputs): for j in range(self.ninputs): num[i, j] *= -1 - return TransferFunction(num, self.den, self.dt) + return TransferFunction( + num, self.den, self.dt, + inputs=self.input_labels, outputs=self.output_labels) def __add__(self, other): """Add two LTI objects (parallel connection).""" @@ -620,8 +622,13 @@ def __mul__(self, other): if isinstance(other, (StateSpace, np.ndarray)): other = _convert_to_transfer_function(other) elif isinstance(other, (int, float, complex, np.number)): - # Multiply by a scaled identity matrix (transfer function) - other = _convert_to_transfer_function(np.eye(self.ninputs) * other) + num = deepcopy(self.num_array) + for i in range(self.noutputs): + for j in range(self.ninputs): + num[i, j] *= other + return TransferFunction( + num, self.den, self.dt, + inputs=self.input_labels, outputs=self.output_labels) if not isinstance(other, TransferFunction): return NotImplemented @@ -669,8 +676,13 @@ def __rmul__(self, other): # Convert the second argument to a transfer function. if isinstance(other, (int, float, complex, np.number)): - # Multiply by a scaled identity matrix (transfer function) - other = _convert_to_transfer_function(np.eye(self.noutputs) * other) + num = deepcopy(self.num_array) + for i in range(self.noutputs): + for j in range(self.ninputs): + num[i, j] *= other + return TransferFunction( + num, self.den, self.dt, + inputs=self.input_labels, outputs=self.output_labels) else: other = _convert_to_transfer_function(other) From 89ff0a2fd6a5c7f580915bc45e99f5ae4e458954 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9F=D0=B5=D1=82=D1=83=D1=88=D0=BA=D0=BE=D0=B2=20=D0=90?= =?UTF-8?q?=2E?= <96879250+marko1olo@users.noreply.github.com> Date: Sun, 16 Aug 2026 16:18:20 +0000 Subject: [PATCH 2/2] fix(xferfcn): use non-in-place multiplication for scalar ops to allow numpy dtype promotion --- control/xferfcn.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/control/xferfcn.py b/control/xferfcn.py index f6c62430d..0b281a3d4 100644 --- a/control/xferfcn.py +++ b/control/xferfcn.py @@ -625,7 +625,7 @@ def __mul__(self, other): num = deepcopy(self.num_array) for i in range(self.noutputs): for j in range(self.ninputs): - num[i, j] *= other + num[i, j] = num[i, j] * other return TransferFunction( num, self.den, self.dt, inputs=self.input_labels, outputs=self.output_labels) @@ -679,7 +679,7 @@ def __rmul__(self, other): num = deepcopy(self.num_array) for i in range(self.noutputs): for j in range(self.ninputs): - num[i, j] *= other + num[i, j] = num[i, j] * other return TransferFunction( num, self.den, self.dt, inputs=self.input_labels, outputs=self.output_labels)