From 23dc96691ee3d7caa35cce1f4ad10ac87ebb800d Mon Sep 17 00:00:00 2001 From: tom Date: Sun, 6 Sep 2026 13:53:16 +0200 Subject: [PATCH 01/20] propagate and allow overriding of signal labels in StateSpace lft function --- control/statesp.py | 8 ++++++-- control/tests/statesp_test.py | 28 ++++++++++++++++++++++++++++ 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/control/statesp.py b/control/statesp.py index 8091e29ed..0a50ff261 100644 --- a/control/statesp.py +++ b/control/statesp.py @@ -1058,7 +1058,7 @@ def feedback(self, other=1, sign=-1): return StateSpace(A, B, C, D, dt) - def lft(self, other, nu=-1, ny=-1): + def lft(self, other, nu=-1, ny=-1, **kwargs): """Return the linear fractional transformation. A definition of the LFT operator can be found in Appendix A.7, @@ -1160,7 +1160,11 @@ def lft(self, other, nu=-1, ny=-1): [D11 + D12 @ H21, D12 @ H22], [Dbar21 @ H11, Dbar22 + Dbar21 @ H12] ]) - return StateSpace(Ares, Bres, Cres, Dres, dt) + + inputs = self.input_labels[:self.ninputs-nu] + other.input_labels[ny:] + outputs = self.output_labels[:self.noutputs-ny] + other.output_labels[nu:] + sys = StateSpace(Ares, Bres, Cres, Dres, dt, inputs=inputs, outputs=outputs) + return StateSpace(sys, **kwargs) def minreal(self, tol=0.0): """Remove unobservable and uncontrollable states. diff --git a/control/tests/statesp_test.py b/control/tests/statesp_test.py index 9b3c677fe..f3de033e7 100644 --- a/control/tests/statesp_test.py +++ b/control/tests/statesp_test.py @@ -1054,6 +1054,34 @@ def test_lft(self): np.testing.assert_allclose(np.array(pk.B).reshape(-1), Bmatlab) np.testing.assert_allclose(np.array(pk.C).reshape(-1), Cmatlab) np.testing.assert_allclose(np.array(pk.D).reshape(-1), Dmatlab) + + def test_lft_labels(self): + """Test that lft() propagates signal labels and allows overrides""" + P = rss( + states=2, outputs=['y1_p', 'y2_p', 'y3_p'], + inputs=['u1_p', 'u2_p'], strictly_proper=True) + K = rss( + states=2, outputs=['y1_k', 'y2_k', 'y3_k'], + inputs=['u1_k', 'u2_k'], strictly_proper=True) + + # case 1: nu = 2, ny = 1 + pk = P.lft(K, nu=2, ny=1) + assert pk.input_labels == ['u2_k'] + assert pk.output_labels == ['y1_p', 'y2_p', 'y3_k'] + + # case 2: nu = 1, ny = 2 + pk = P.lft(K, nu=1, ny=2) + assert pk.input_labels == ['u1_p'] + assert pk.output_labels == ['y1_p', 'y2_k', 'y3_k'] + + # test that keyword arguments passed to lft() override the labels + pk = P.lft( + K, nu=2, ny=0, + inputs=['u1', 'u2'], outputs=['y1', 'y2', 'y3', 'y4'], + states=['x1', 'x2', 'x3', 'x4']) + assert pk.input_labels == ['u1', 'u2'] + assert pk.output_labels == ['y1', 'y2', 'y3', 'y4'] + assert pk.state_labels == ['x1', 'x2', 'x3', 'x4'] def test_repr(self, sys322): """Test string representation""" From e6a3e1bbef21ff58502901a6a408a015dd050bf1 Mon Sep 17 00:00:00 2001 From: tom Date: Sun, 6 Sep 2026 14:54:13 +0200 Subject: [PATCH 02/20] add a check for validity of given nu and ny variables to statespace lft function --- control/statesp.py | 13 +++++++++++-- control/tests/statesp_test.py | 12 +++++++++++- 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/control/statesp.py b/control/statesp.py index 0a50ff261..9694b05d0 100644 --- a/control/statesp.py +++ b/control/statesp.py @@ -1085,13 +1085,22 @@ def lft(self, other, nu=-1, ny=-1, **kwargs): """ other = _convert_to_statespace(other) + + # dimension check + if ny > self.noutputs or ny > other.ninputs: + raise ValueError( + "ny can't exceed the number of outputs of self or " + "inputs of other") + if nu > self.ninputs or nu > other.noutputs: + raise ValueError( + "nu can't exceed the number of inputs of self or " + "outputs of other") + # maximal values for nu, ny if ny == -1: ny = min(other.ninputs, self.noutputs) if nu == -1: nu = min(other.noutputs, self.ninputs) - # dimension check - # TODO dt = common_timebase(self.dt, other.dt) diff --git a/control/tests/statesp_test.py b/control/tests/statesp_test.py index f3de033e7..554ff5e28 100644 --- a/control/tests/statesp_test.py +++ b/control/tests/statesp_test.py @@ -1054,7 +1054,17 @@ def test_lft(self): np.testing.assert_allclose(np.array(pk.B).reshape(-1), Bmatlab) np.testing.assert_allclose(np.array(pk.C).reshape(-1), Cmatlab) np.testing.assert_allclose(np.array(pk.D).reshape(-1), Dmatlab) - + + @pytest.mark.parametrize('nu, ny, errmatch', + [(3, -1, "nu can't exceed"), + (-1, 3, "ny can't exceed")]) + def test_lft_invalid(self, nu, ny, errmatch): + """Test that lft() rejects out-of-range nu, ny values""" + P = rss(states=2, outputs=2, inputs=2) + K = rss(states=2, outputs=2, inputs=2) + with pytest.raises(ValueError, match=errmatch): + P.lft(K, nu, ny) + def test_lft_labels(self): """Test that lft() propagates signal labels and allows overrides""" P = rss( From 37153cf3d3d1e2e65e03523969fd0b881f7b6c63 Mon Sep 17 00:00:00 2001 From: tom Date: Wed, 9 Sep 2026 18:00:19 +0200 Subject: [PATCH 03/20] update Statespace lft function docstring to directly reflect its functionality and update reference --- control/statesp.py | 47 +++++++++++++++++++++++++++++++++++++++------- 1 file changed, 40 insertions(+), 7 deletions(-) diff --git a/control/statesp.py b/control/statesp.py index 9694b05d0..f755a5ec4 100644 --- a/control/statesp.py +++ b/control/statesp.py @@ -1061,27 +1061,60 @@ def feedback(self, other=1, sign=-1): def lft(self, other, nu=-1, ny=-1, **kwargs): """Return the linear fractional transformation. - A definition of the LFT operator can be found in Appendix A.7, - page 512 in [1]_. An alternative definition can be found here: - https://www.mathworks.com/help/control/ref/lft.html + Forms the Redheffer star product of two LTI systems [1]_. This + connects the first `nu` outputs of `other` to the last `nu` + inputs of `self`, and the last `ny` outputs of `self` to the + first `ny` inputs of `other`. If `other` has fewer inputs and + outputs than `self`, this forms the lower LFT of `self` and + `other`. If `self` has fewer inputs and outputs than `other`, + this forms the upper LFT of `other` and `self`. Parameters ---------- other : `StateSpace` The lower LTI system. ny : int, optional - Dimension of (plant) measurement output. + Dimension of (plant) measurement output that is connected + to `other`. Must not exceed the number of outputs of + `self` or the number of inputs of `other`. If not + specified, defaults to the largest value allowed by the + shapes of `self` and `other`. nu : int, optional - Dimension of (plant) control input. + Dimension of (plant) control input that is generated by + `other`. Must not exceed the number of inputs of `self` + or the number of outputs of `other`. If not specified, + defaults to the largest value allowed by the shapes of + `self` and `other`. Returns ------- `StateSpace` + The result of the linear fractional transformation, with + input and output labels inherited from the corresponding + signals of `self` and `other` unless overridden. + + Other Parameters + ---------------- + inputs, outputs, states : int, list of str, or None, optional + Description of the system inputs, outputs, and states. + If not specified, these are inherited from the + corresponding signals of `self` and `other`. See + `InputOutputSystem` for more information. + name : string, optional + Set the name of the resulting system. + + Raises + ------ + ValueError + If `ny` exceeds the number of outputs of `self` or the + number of inputs of `other`, or if `nu` exceeds the number + of inputs of `self` or the number of outputs of `other`. References ---------- - .. [1] S. Skogestad, Multivariable Feedback Control. Second - edition, 2005. + .. [1] J. Doyle, A. Packard, and K. Zhou, "Review of LFTs, + LMIs, and mu," Proceedings of the 30th IEEE Conference on + Decision and Control, Brighton, England, 1991, pp. 1227-1232. """ other = _convert_to_statespace(other) From 6007d4df6a842a3d4bd66dcd337cc6ef2f7c1f56 Mon Sep 17 00:00:00 2001 From: tom Date: Sat, 12 Sep 2026 15:32:13 +0200 Subject: [PATCH 04/20] add general lft function that can be used with any InputOutputSystem --- control/bdalg.py | 134 ++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 133 insertions(+), 1 deletion(-) diff --git a/control/bdalg.py b/control/bdalg.py index 0ed490084..9745b76e4 100644 --- a/control/bdalg.py +++ b/control/bdalg.py @@ -21,9 +21,10 @@ from . import statesp as ss from . import xferfcn as tf from .iosys import InputOutputSystem +from .nlsys import interconnect __all__ = ['series', 'parallel', 'negate', 'feedback', 'append', 'connect', - 'combine_tf', 'split_tf'] + 'lft', 'combine_tf', 'split_tf'] def series(*sys, **kwargs): @@ -313,6 +314,137 @@ def feedback(sys1, sys2=1, sign=-1, **kwargs): sys.update_names(**kwargs) return sys +def lft(sys1, sys2, nu=-1, ny=-1, **kwargs): + """Linear fractional transformation (LFT) of two I/O systems. + + Forms the Redheffer star product of `sys1` and `sys2` [1]_. + This connects the last `nu` outputs of `sys2` to the first `nu` + inputs of `sys1`, and the first `ny` outputs of `sys1` to the + first `ny` inputs of `sys2`. If `sys2` has fewer inputs and + outputs than `sys1`, this forms the lower LFT of `sys1` and + `sys2`. If `sys1` has fewer inputs and outputs than `sys2`, + this forms the upper LFT of `sys2` and `sys1`. + + Parameters + ---------- + sys1, sys2 : `InputOutputSystem` + I/O systems to perform linear fractional transformation on. + ny : int, optional + Dimension of (plant) measurement output that is connected to + `sys2`. Must not exceed the number of outputs of `sys1` or + the number of inputs of `sys2`. If not specified, defaults + to the largest value allowed by the shapes of `sys1` and + `sys2`. + nu : int, optional + Dimension of (plant) control input that is generated by + `sys2`. Must not exceed the number of inputs of `sys1` or + the number of outputs of `sys2`. If not specified, defaults + to the largest value allowed by the shapes of `sys1` and + `sys2`. + + Returns + ------- + out : `InputOutputSystem` + The result of the linear fractional transformation, with + input and output labels inherited from the corresponding + signals of `sys1` and `sys2` unless overridden. + + Other Parameters + ---------------- + inputs, outputs, states : int, list of str, or None, optional + Description of the system inputs, outputs, and states. If + not specified, these are inherited from the corresponding + signals of `sys1` and `sys2`. See `InputOutputSystem` for + more information. + name : string, optional + Set the name of the resulting system. + + Raises + ------ + ValueError + If `ny` exceeds the number of outputs of `sys1` or the + number of inputs of `sys2`, or if `nu` exceeds the number of + inputs of `sys1` or the number of outputs of `sys2`. + TypeError + If `sys1` or `sys2` is not an I/O system, or cannot be + converted to one. + + See Also + -------- + append, feedback, interconnect, negate, parallel, series + + Notes + ----- + This function is a wrapper for `StateSpace.lft`. If `sys1` and + `sys2` are `StateSpace` systems, or can be converted to + `StateSpace` systems, the linear-algebraic implementation in + `StateSpace.lft` is used directly. For other I/O systems, + the same interconnection is built using `interconnect`. + + References + ---------- + .. [1] J. Doyle, A. Packard, and K. Zhou, "Review of LFTs, + LMIs, and mu," Proceedings of the 30th IEEE Conference on + Decision and Control, Brighton, England, 1991, pp. 1227-1232. + + """ + if not isinstance(sys1, InputOutputSystem): + raise TypeError("sys1 must be an I/O system") + elif not isinstance(sys2, InputOutputSystem): + raise TypeError("sys2 must be an I/O system") + + # Fill in default values for nu, ny + if ny == -1: + ny = min(sys2.ninputs, sys1.noutputs) + if nu == -1: + nu = min(sys2.noutputs, sys1.ninputs) + + # Check that nu, ny are within bounds + if ny > sys1.noutputs or ny > sys2.ninputs: + raise ValueError( + "ny can't exceed the number of outputs of sys1 or " + "inputs of sys2") + if nu > sys1.ninputs or nu > sys2.noutputs: + raise ValueError( + "nu can't exceed the number of inputs of sys1 or " + "outputs of sys2") + + if isinstance(sys1, (ss.StateSpace, tf.TransferFunction)) and \ + isinstance(sys2, (ss.StateSpace, tf.TransferFunction)): + # Both systems can be converted to StateSpace; use the fast, + # linear-algebraic implementation in StateSpace.lft directly. + sys1_ss = ss._convert_to_statespace(sys1) + return sys1_ss.lft(sys2, nu, ny, **kwargs) + + + n1i, n1o = sys1.ninputs, sys1.noutputs + n2i, n2o = sys2.ninputs, sys2.noutputs + + connections = [ + [(0, n1i - nu + i), (1, i)] for i in range(nu) + ] + [ + [(1, i), (0, n1o - ny + i)] for i in range(ny) + ] + + inplist = [(0, i) for i in range(n1i - nu)] + \ + [(1, i) for i in range(ny, n2i)] + + outlist = [(0, i) for i in range(n1o - ny)] + \ + [(1, i) for i in range(nu, n2o)] + + if not 'inputs' in kwargs: + inputs = sys1.input_labels[:sys1.ninputs-nu] + sys2.input_labels[ny:] + kwargs['inputs'] = inputs + + if not 'outputs' in kwargs: + outputs = sys1.output_labels[:sys1.noutputs-ny] + sys2.output_labels[nu:] + kwargs['outputs'] = outputs + + return interconnect( + [sys1, sys2], connections=connections, + inplist=inplist, outlist=outlist, **kwargs) + + def append(*sys, **kwargs): """append(sys1, sys2[, ..., sysn]) From 47205b140153ff3503ad39a9d957c37dda7bfc4f Mon Sep 17 00:00:00 2001 From: tom Date: Sat, 12 Sep 2026 15:34:30 +0200 Subject: [PATCH 05/20] add lft function to kwarg tests --- control/tests/kwargs_test.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/control/tests/kwargs_test.py b/control/tests/kwargs_test.py index 566b35a28..80ba883ac 100644 --- a/control/tests/kwargs_test.py +++ b/control/tests/kwargs_test.py @@ -104,6 +104,7 @@ def test_kwarg_search(module, prefix): (control.dlqr, 1, 0, ([[1, 0], [0, 1]], [[1]]), {}), (control.drss, 0, 0, (2, 1, 1), {}), (control.feedback, 2, 0, (), {}), + (control.lft, 2, 0, (), {}), (control.flatsys.flatsys, 1, 0, (), {}), (control.input_output_response, 1, 0, ([0, 1, 2], [1, 1, 1]), {}), (control.lqe, 1, 0, ([[1]], [[1]]), {}), @@ -139,6 +140,7 @@ def test_kwarg_search(module, prefix): (control.StateSpace.sample, 1, 0, (0.1,), {}), (control.StateSpace, 0, 0, ([[-1, 0], [0, -1]], [[1], [1]], [[1, 1]], 0), {}), + (control.StateSpace.lft, 2, 0, (), {}), (control.TransferFunction, 0, 0, ([1], [1, 1]), {})] ) def test_unrecognized_kwargs(function, nsssys, ntfsys, moreargs, kwargs, @@ -273,6 +275,7 @@ def test_response_plot_kwargs(data_fcn, plot_fcn, mimo): 'interconnect': interconnect_test.test_interconnect_exceptions, 'time_response_plot': timeplot_test.test_errors, 'linearize': test_unrecognized_kwargs, + 'lft': test_unrecognized_kwargs, 'lqe': test_unrecognized_kwargs, 'lqr': test_unrecognized_kwargs, 'LTI.forced_response': statesp_test.test_convenience_aliases, @@ -348,6 +351,7 @@ def test_response_plot_kwargs(data_fcn, plot_fcn, mimo): interconnect_test.test_interconnect_exceptions, 'StateSpace.__init__': test_unrecognized_kwargs, 'StateSpace.initial_response': timeresp_test.test_timeresp_aliases, + 'StateSpace.lft': test_unrecognized_kwargs, 'StateSpace.sample': test_unrecognized_kwargs, 'TimeResponseData.__call__': trdata_test.test_response_copy, 'TimeResponseData.plot': timeplot_test.test_errors, From 6776e2d22ce60d52c15e867481cc530e1d1e5aa9 Mon Sep 17 00:00:00 2001 From: tom Date: Sat, 12 Sep 2026 16:54:41 +0200 Subject: [PATCH 06/20] add unittests for lft function with LTI systems --- control/tests/bdalg_test.py | 83 ++++++++++++++++++++++++++++++++++++- 1 file changed, 82 insertions(+), 1 deletion(-) diff --git a/control/tests/bdalg_test.py b/control/tests/bdalg_test.py index 63d3c42cf..f2917a86e 100644 --- a/control/tests/bdalg_test.py +++ b/control/tests/bdalg_test.py @@ -6,7 +6,7 @@ import control as ctrl import numpy as np import pytest -from control.bdalg import _ensure_tf, append, connect, feedback +from control.bdalg import _ensure_tf, append, connect, feedback, lft from control.lti import poles, zeros from control.statesp import StateSpace from control.tests.conftest import assert_tf_close_coeff @@ -341,6 +341,87 @@ def testConnect(self, tsys): connect(sys, Q, [2], [1, -1]) +class TestLft: + """Tests for the lft function in bdalg.py.""" + + @pytest.mark.parametrize('nu, ny', [(-1, -1), (2, 1), (1, 2)]) + def test_lft_matches_statespace_method(self, nu, ny): + """lft() should exactly reproduce StateSpace.lft() for SS inputs.""" + P = ctrl.rss(states=3, outputs=3, inputs=3, strictly_proper=True) + K = ctrl.rss(states=3, outputs=3, inputs=3, strictly_proper=True) + + ans = lft(P, K, nu, ny) + ref = P.lft(K, nu, ny) + + np.testing.assert_array_almost_equal(ans.A, ref.A) + np.testing.assert_array_almost_equal(ans.B, ref.B) + np.testing.assert_array_almost_equal(ans.C, ref.C) + np.testing.assert_array_almost_equal(ans.D, ref.D) + + @pytest.mark.parametrize('nu, ny', [(-1, -1), (1, 1)]) + def test_lft_tf_inputs(self, nu, ny): + """lft() should accept TransferFunction inputs, like + StateSpace.lft() does via conversion.""" + P_ss = ctrl.rss(states=2, outputs=2, inputs=2, strictly_proper=True) + K_ss = ctrl.rss(states=2, outputs=2, inputs=2, strictly_proper=True) + P_tf = ctrl.tf(P_ss) + K_tf = ctrl.tf(K_ss) + + ref = P_ss.lft(K_ss, nu, ny) + ans = lft(P_tf, K_tf, nu, ny) + + for s in [0, 1, 1j]: + np.testing.assert_allclose(ans(s), ref(s), atol=1e-6) + + @pytest.mark.parametrize('nu, ny, errmatch', + [(3, -1, "nu can't exceed"), + (-1, 3, "ny can't exceed")]) + def test_lft_invalid_nu_ny(self, nu, ny, errmatch): + """Test that lft() rejects out-of-range nu, ny values.""" + P = ctrl.rss(states=2, outputs=2, inputs=2, strictly_proper=True) + K = ctrl.rss(states=2, outputs=2, inputs=2, strictly_proper=True) + with pytest.raises(ValueError, match=errmatch): + lft(P, K, nu, ny) + + def test_lft_labels(self): + """Test that lft() propagates signal labels and allows overrides.""" + P = ctrl.rss( + states=2, outputs=['y1_p', 'y2_p', 'y3_p'], + inputs=['u1_p', 'u2_p'], strictly_proper=True) + K = ctrl.rss( + states=2, outputs=['y1_k', 'y2_k', 'y3_k'], + inputs=['u1_k', 'u2_k'], strictly_proper=True) + + # case 1: nu = 2, ny = 1 + pk = lft(P, K, nu=2, ny=1) + assert pk.input_labels == ['u2_k'] + assert pk.output_labels == ['y1_p', 'y2_p', 'y3_k'] + + # case 2: nu = 1, ny = 2 + pk = lft(P, K, nu=1, ny=2) + assert pk.input_labels == ['u1_p'] + assert pk.output_labels == ['y1_p', 'y2_k', 'y3_k'] + + # test that keyword arguments passed to lft() override the labels + pk = lft( + P, K, nu=2, ny=0, + inputs=['u1', 'u2'], outputs=['y1', 'y2', 'y3', 'y4'], + states=['x1', 'x2', 'x3', 'x4']) + assert pk.input_labels == ['u1', 'u2'] + assert pk.output_labels == ['y1', 'y2', 'y3', 'y4'] + assert pk.state_labels == ['x1', 'x2', 'x3', 'x4'] + + def test_lft_bad_types(self): + """Non-system, non-convertible arguments should raise TypeError + rather than being silently routed into the general/interconnect + path.""" + P = ctrl.rss(states=2, outputs=2, inputs=2) + with pytest.raises(TypeError): + lft('hello world', P) + with pytest.raises(TypeError): + lft(P, 'hello world') + + @pytest.mark.parametrize( "op, nsys, ninputs, noutputs, nstates", [ (ctrl.series, 2, 1, 1, 4), From bb06493a0b406e2f1962112ef8a8efdb8214bc83 Mon Sep 17 00:00:00 2001 From: tom Date: Sat, 12 Sep 2026 16:54:57 +0200 Subject: [PATCH 07/20] add unittest for lft function with general InputOutputSystem input update iosys_test test_lft docstring --- control/tests/iosys_test.py | 76 +++++++++++++++++++++++++++++++++++++ 1 file changed, 76 insertions(+) diff --git a/control/tests/iosys_test.py b/control/tests/iosys_test.py index 5d741ae83..cb42d7588 100644 --- a/control/tests/iosys_test.py +++ b/control/tests/iosys_test.py @@ -594,6 +594,76 @@ def test_feedback(self, tsys): lti_t, lti_y = ct.forced_response(linsys, T, U, X0) np.testing.assert_allclose(ios_y, lti_y,atol=0.002,rtol=0.) + def test_lft(self, tsys): + """Test that lft() with a NonlinearIOSystem matches a hand-built + LFT using interconnect() and labels propagate correctly.""" + # Set up parameters for simulation + T, U, X0 = tsys.T, tsys.U, tsys.X0 + + sys1_ss = ct.rss(states=2, inputs=2, outputs=3, strictly_proper=True) + sys2_ss = ct.rss(states=2, inputs=2, outputs=3, strictly_proper=True) + + sys1_nl = ct.NonlinearIOSystem( + lambda t, x, u, params: sys1_ss.A @ x + sys1_ss.B @ u, + lambda t, x, u, params: sys1_ss.C @ x + sys1_ss.D @ u, + states=2, inputs=['u1a', 'u1b'], outputs=['y1a', 'y1b', 'y1c'], + name='sys1') + sys2_nl = ct.NonlinearIOSystem( + lambda t, x, u, params: sys2_ss.A @ x + sys2_ss.B @ u, + lambda t, x, u, params: sys2_ss.C @ x + sys2_ss.D @ u, + states=2, inputs=['u2a', 'u2b'], outputs=['y2a', 'y2b', 'y2c'], + name='sys2') + + # case 1: nu = -1, ny = -1 + sys_lft = ct.lft(sys1_nl, sys2_nl) + sys_interconnect = ct.interconnect( + [sys1_nl, sys2_nl], + connections=[ + ['sys1.u1a', 'sys2.y2a'], + ['sys1.u1b', 'sys2.y2b'], + ['sys2.u2a', 'sys1.y1b'], + ['sys2.u2b', 'sys1.y1c'], + ], + inplist=[], + outlist=['sys1.y1a', 'sys2.y2c'], + ) + + lft_t, lft_y = ct.input_output_response(sys_lft, T, X0=X0) + ic_t, ic_y = ct.input_output_response(sys_interconnect, T, X0=X0) + np.testing.assert_allclose(lft_y, ic_y, atol=1e-10) + # check label propagation + assert sys_lft.output_labels == ['y1a', 'y2c'] + + # case 2: nu = 1, ny = 2 + sys_lft = ct.lft(sys1_nl, sys2_nl, 1, 2) + sys_interconnect = ct.interconnect( + [sys1_nl, sys2_nl], + connections=[ + ['sys1.u1b', 'sys2.y2a'], + ['sys2.u2a', 'sys1.y1b'], + ['sys2.u2b', 'sys1.y1c'], + ], + inplist=['sys1.u1a'], + outlist=['sys1.y1a', 'sys2.y2b', 'sys2.y2c'], + ) + lft_t, lft_y = ct.input_output_response(sys_lft, T, U) + ic_t, ic_y = ct.input_output_response(sys_interconnect, T, U) + np.testing.assert_allclose(lft_y, ic_y, atol=1e-10) + # check label propagation + assert sys_lft.input_labels == ['u1a'] + assert sys_lft.output_labels == ['y1a', 'y2b', 'y2c'] + + # check input, output and name overriding + sys_lft = ct.lft( + sys1_nl, sys2_nl, 1, 2, + inputs=['u'], + outputs=['y1','y2','y3'], + name='new_sys' + ) + assert sys_lft.input_labels == ['u'] + assert sys_lft.output_labels == ['y1', 'y2', 'y3'] + assert sys_lft.name == 'new_sys' + def test_bdalg_functions(self, tsys): """Test block diagram functions algebra on I/O systems""" # Set up parameters for simulation @@ -640,6 +710,12 @@ def test_bdalg_functions(self, tsys): ios_t, ios_y = ct.input_output_response(iosys_feedback, T, U, X0) np.testing.assert_allclose(ios_y, lin_y,atol=0.002,rtol=0.) + linsys_lft = ct.lft(linsys1, linsys2, nu=1, ny=1) + iosys_lft = ct.lft(linio1, linio2, nu=1, ny=1) + lin_t, lin_y = ct.forced_response(linsys_lft, T, U, X0) + ios_t, ios_y = ct.input_output_response(iosys_lft, T, U, X0) + np.testing.assert_allclose(ios_y, lin_y,atol=0.002,rtol=0.) + def test_algebraic_functions(self, tsys): """Test algebraic operations on I/O systems""" # Set up parameters for simulation From d74973dfafc0dfa3fc3b2aab7dbd8b08bf3d56a6 Mon Sep 17 00:00:00 2001 From: tom Date: Sat, 12 Sep 2026 17:23:35 +0200 Subject: [PATCH 08/20] add lft to function reference --- doc/functions.rst | 1 + 1 file changed, 1 insertion(+) diff --git a/doc/functions.rst b/doc/functions.rst index 8432f7fcf..d02d4469e 100644 --- a/doc/functions.rst +++ b/doc/functions.rst @@ -56,6 +56,7 @@ System Interconnections parallel negate feedback + lft interconnect append combine_tf From 16942870fa44b008986d27042d0aa9225c3e7d20 Mon Sep 17 00:00:00 2001 From: tom Date: Sat, 12 Sep 2026 20:45:53 +0200 Subject: [PATCH 09/20] add narrative documentation for lft function --- control/bdalg.py | 24 ++++++--------- doc/figures/bdalg-lft.png | Bin 0 -> 38054 bytes doc/figures/bdalg-ullft.png | Bin 0 -> 51702 bytes doc/iosys.rst | 59 ++++++++++++++++++++++++++++++++++++ 4 files changed, 69 insertions(+), 14 deletions(-) create mode 100644 doc/figures/bdalg-lft.png create mode 100644 doc/figures/bdalg-ullft.png diff --git a/control/bdalg.py b/control/bdalg.py index 9745b76e4..4f8670cb7 100644 --- a/control/bdalg.py +++ b/control/bdalg.py @@ -315,11 +315,11 @@ def feedback(sys1, sys2=1, sign=-1, **kwargs): return sys def lft(sys1, sys2, nu=-1, ny=-1, **kwargs): - """Linear fractional transformation (LFT) of two I/O systems. + """Linear fractional transformation of two I/O systems. Forms the Redheffer star product of `sys1` and `sys2` [1]_. - This connects the last `nu` outputs of `sys2` to the first `nu` - inputs of `sys1`, and the first `ny` outputs of `sys1` to the + This connects the first `nu` outputs of `sys2` to the last `nu` + inputs of `sys1`, and the last `ny` outputs of `sys1` to the first `ny` inputs of `sys2`. If `sys2` has fewer inputs and outputs than `sys1`, this forms the lower LFT of `sys1` and `sys2`. If `sys1` has fewer inputs and outputs than `sys2`, @@ -393,12 +393,6 @@ def lft(sys1, sys2, nu=-1, ny=-1, **kwargs): elif not isinstance(sys2, InputOutputSystem): raise TypeError("sys2 must be an I/O system") - # Fill in default values for nu, ny - if ny == -1: - ny = min(sys2.ninputs, sys1.noutputs) - if nu == -1: - nu = min(sys2.noutputs, sys1.ninputs) - # Check that nu, ny are within bounds if ny > sys1.noutputs or ny > sys2.ninputs: raise ValueError( @@ -409,14 +403,16 @@ def lft(sys1, sys2, nu=-1, ny=-1, **kwargs): "nu can't exceed the number of inputs of sys1 or " "outputs of sys2") - if isinstance(sys1, (ss.StateSpace, tf.TransferFunction)) and \ - isinstance(sys2, (ss.StateSpace, tf.TransferFunction)): - # Both systems can be converted to StateSpace; use the fast, - # linear-algebraic implementation in StateSpace.lft directly. + # maximal values for nu, ny + if ny == -1: + ny = min(sys2.ninputs, sys1.noutputs) + if nu == -1: + nu = min(sys2.noutputs, sys1.ninputs) + + if isinstance(sys1, ss.StateSpace) and isinstance(sys2, ss.StateSpace): sys1_ss = ss._convert_to_statespace(sys1) return sys1_ss.lft(sys2, nu, ny, **kwargs) - n1i, n1o = sys1.ninputs, sys1.noutputs n2i, n2o = sys2.ninputs, sys2.noutputs diff --git a/doc/figures/bdalg-lft.png b/doc/figures/bdalg-lft.png new file mode 100644 index 0000000000000000000000000000000000000000..ac041f64bdb7763cfe8b40b90b0f962656d220f0 GIT binary patch literal 38054 zcmdqJ1yI#%`!>2nLO?`OK#>weq?J^tuC4KH6xc~3_e)GCzY#y}kOqGc-j~1k5QXAoME*VIk|gGgLeZmcNlR$B7|ah7epT*1 zI9M^K<+h+`zJ$4R?%fL?GZnu!aVBnyUxQ17&rjZJ2njiPDL^v$PLw(;OXx4Q(GK=E zMF&4h%U(U)pB{R+Sn4*)Gvv|oScH!BRYU3K`_f*K%+gV}%=LRW_qV2FJx{_SP%av$ zB#Dv#;bO!he=PGJ{doKT<3G|*1=ESy9lXEcPTYHYWOkp)c*{Skx~6sy&5TriIO< zx7y#|vCp#|)s6QR>!50VaNHt_H%4n?32?Q^Zlmf8|`v* z@8+`0N7(P>R!=s*ZC#l=jv}Ls!$Y0^e4OA^U+Ak^I?ti6E8j1kDK*&JUT^M7Q&;Xz z*WAlq9kefh*AgQX?KIcZU+y;MP0$37Lf5{7LXi=``$NoibaRcX@Ohr>x_Ev(F`e7U z$>a2^>w3^d0e*N3-_3_pymGH-`AVD@xMMvw-tF~k8$7N#NMKTUb1l1kzc@y8Blcjo zHu#hK_H4?2cD8;=53lV2Z(}V9N}k<^0wo?XKUCEDl8Uc^UB8%jzLiY_Yu(#|wd!to z!=c+V2`d}kY_D_=>RMYG9d(Ce+1TOyx-2Qw{7JXa&e^wGo*H&R${B}Q4G;GfGwg#tvNh7+lv$-VD94$~dXOeg)PI9U( zPD)}a?ljhA#iZms9V&zhP8C(lOY`Z`(g;5`QS-}vW1h#q%{tT6nRUP4$l!gdUgB)4 z>%snhXR)MJc&$z9p2uU9I1U5&0`*Pj`AE!gasItL3+1{^?bJKpgb#LClZ~tVf(_Vs z669X-Gs*>@+_7bi8_G3rY%;(>g-9btq=e~`k{FM9G(xy#z!71HMTdfwC?se9ctbI2 zt=wqnxktXnIB-X8PFLYA=fYYN3U$k?4F^>h((?w5Yd+VLd1>^~&v5Oiw}wyp->_+O zre~&N&_uT|a!ypdXroQf7iQZRQ1mOObx^3;j+TvqOat?v&UDoTzkB6RN?CNUXeFKd zbs?85Dt!ngvNOvs3fSo>jgX?o^pPVf-riWS{_tdbHY7VE{q572vN|T_BVQjUyKOE? zaFx5RTj}bbM1|-e&yf?se$Rx>+jn*B8EKT~-kjle+aiGxmns#4)pkLOg;uq6jkL%z zE>wjZ^6Jy@3Hb?E#rFT0*7atUZCeR7ycYA^k1QC<(l1d;QV8E&a@bq%9mG4BZ^c2S z`NI>RXwSFS&NKS2n#z&iV#b#^KLRAzzwsHm<&znohh`V}nr*CjZMr#2&BZw~Ki zozv3#ba&h}W#?00sas*u*i+(Nv10Q^3iT9~m``8Nq3FMzfn9krAfEeVZ+ohAbA))1 zm56uSRXDXem-S8k1}wn9vJ{%@zV)Cp zS^1(H`@%qeUT633pN+Nl#3;4X;6O$(5Kh3JSGV`D8EC}evv0Jvh z-}o}|IT#-`rVLx{wf^(1VU$&OdjIfDC#%8NFa4iQ{aJJiZ1i;JVmC7&C!;{kCtc)n6Lr?{BPJb zQcrW>q3S4*=+kAunKB{PN?}4xi=<4vJm~%!MJjdv8<dBdq6HF^q##FA^yR(<)s z1{Vuh%65M%s)I$P3WTNmtj~7V3a>WtT*}rjf8vn`-mXA>9K|jRJ~LG~`h*9Lkq-hY zDJJNya;MrcFP(g=cdcRv!jmHYBo{5dzr0XokB<@uv)p~f?>Rq^pYeF5hEggj(X#b} z#Z=R~D{z9UwprO_+pNQ9-jiJ9?+dU%p-wP@k6A;aO2%|1U&@S=`J?kPTV|`B*Sz6e zXSQK^$XgBHYe8jiuBtLb*%P4TjS%B>+Wq6unWZnZ`YR*b4=Z0AOw;`J@v0I;s)P<5 z*2kY>7AUz4sDHb1=oVlZMRi`1LI6TM-@a@sSs$0K1&isNuiQe*+iS=zmMn>`wn>r7 z{IG>c`xJ>g@p#LOvM*0y$d!Aecrj{yWHokZ?H$NG(FgmR<;I>iu(4B7)>#g#uTfr? z5kK^4vj~eiMWHfU=FvRUo}fHl>8qbV1R=l{^Aj9$cz&>uYrfS=?EC_8!k#g}C?sl0 zqDjMPMO-5RYl+&-y?5%lmkfc5-wW;iVJeF@b3JZJ!*|%%sqrrSehX<%;9GvM3a?%@2v_ zSZ@ovZP+{yKh=60+_cH_U}sc5ir`j2+f#PMDHR^7 zFSFChVr1@5{_ILWZ6%G3l{cH7_HTQ-c3LOI+$Z9txn&9+3ALDjNa)yV)ig7_P;GM+ zU=u`t6zW65tdZq=2R{)`N#eupS*DgGesAP@9)6cg5{IXP_zWt<_5{a$#4k`@Hd1Ks zvtSq~l-WgiB|TCS&<~#08N)sNyneqn9OlChs^j;exFG?Kff6scgsmWyB-RshS^oI$ zn(@=UqDo)l=x@&mN}iCw+yDMH;UT=Xz0v~@KD2^QkP?MD%`v2X_{RUkUuiJ;!VavT&F1FTNe94}2e;wS ztHJ?ZzAQ=n&*vQr2|2+bJ_$!e2KirH5;EmKpNt}W@xOc1GEVyIN;pa{A6PDf9NL?* zEw<|L=W3Z#&#B|^48%Q8A?Hsb1F z2K|HS7F!|;cJ%M5=D9x~pX{w`hjAH-s;Az$R4evu=0EM(c%J3W?*7iw6l4VM?61EA z9v|#&Hh&OtYl@Tde>(CNfJU-vf^71wuq%hb-*L9%0_}So#UI~;Z2z=dQ$lsT!Tc=e zuU%(3p5T#I4}*dbjpMP^P-Jp-28~dxHOL-{%AtgRd@iuVy!$ zt4IVQA#t|xTK6=4y8F2@_zS%o;yOeSw|-chF{qfnw!Oc*dOHu(d4eTa* zk18>OQ?n_t{BQLhyf|>;wdzFb6+lfpj{uenKm`~)<8k`Jf4Z`4E1-hr??D%v|E&A~ ziom+V!x-$Z>H_3wC65P005Z~b-)=zu1`_Vc{lu?#W+k&yZ$sralSA>WP! z=eV!6%k5`#L8`>s4GX$5B?XcR0ihX0SMPyTt=h32GXNES{MLYKZ3J$A-5{)#q_M2zS2q9j?BeB_zVjOIk zadYTl>pM7|D9F(_Cu_1w76m&C?F}C`^Ywl)co^@yp_4svQIqA)M=yQmv+{>>;DnbjqCCN!p?VrIT(!9`#y_RW8Xo=LSLth;3i4jU8 zLF?ZZkv9c<=xqDK{<{0e2R|G7)AXqHU*Knkw#%#B;41qQj{!KwJA!`(2|@n~o^fsn zR)@lb4~i|qt?@w4;yQM{FKv46D>aLTH)x`+IB)b@`p?hBlhuxTY{sytCo6T05Z{BW z8FCeg-qA8H$6}=(ouU)9q2jkeZ_3@8LtqwH`EG97-OuqQl+8XAQyGGh8?J;J8Co29 zPxf4e$VvJk`Xd2(Yyfg!vk&3f`BrjHy}pyQ!hP%3s89y%jTAn1ox9}#ZbIma=W`q9 zH9iAxAm}MUDH}v(PV2GO{xS9oQj~?=7M-2YJk18#?8RX;J)`sf;x!|$#}hA@C7;lH z?(aHROSxrN2Pm*S`qjlU^OG+_< zMzn^E(oTML6~Od8xAHfvnhl2(QtUE5l8w_7?^p7~{RIA}fZYz^{F@}j{(~<)7oI;atUia1NhySOqO<9j$T90QjrK?{02+|HR(tb zY_G1(j;w<#7L4A3kQT;#`-5ZoR`rXsQ{YzEK~=Lgj~A?iQjNhhLQQM4oz03|rDhw8 z!`1=<83v`r9Ia_FZ(`b78jwQLBT75psyjz4-imK;XS0y_c0QMP!+kB_lj<9fFVk~A z7>|K!AmXe+(F5~7`}1N?-1RoK1fjgWaPvW8eJG=U(Z+!F-ZA_U@xzSl-yyOBw4VKv z?k3QMORqFvs9NK&X=hRYKJL2gIhR=&1w39~{xeeHWiKS$P(CSD4TG}l_K;o40>7K8 zVuth!G7F%YratAhG%TA@a_E@hlTew7Ek`}bF{ugo_&X{W$V#`*yAb+zvz@@ zWqu<5bb^6$1a`}oZiHIMnSJu&+MC5duCnJl$D=*>w`%<;Tu2W=0=~--PP6#NuP?!gW?u`*P4&A??wEoLVJWl+Fn zj|Am#wzs9R-6HuGTs5W#ne(qX;(mhB=cr@5>I8-z!$_`JIRZm2V343rA1A1*@l7=a{}!?80Nb~~zM zsZ8jpmRv4CV&a+B1G%{@&Ee~%qQW6PpJv9(7Q>yEcE_%MYI1bKb9suWasq>o#=1Qp(E0u^U{Yv{AZr}HiL2&o7u-~F8Z;d^F zS)KRvREX7;+aGNBZ3oN*9cPUEKk@OgM186A?I4-CIsM)g$}XzkGR?L<17(mY#G=aa zFW-QTqVL(;f~YiKYV?*`H53D5UBE@cp`QX06#u^5(p)z6FH>4E*& zp`0S<5VZA;sc3c&Qy!9PEc4h=0)wAND-5{q71tL{e{g#`aJR}dia#P^%DT{gbfjf` zwLK_wYRfkxa=BaE@SR0-RDU}S9mj|^6Ah|;v(NO^J78J+DYf!NGub-VF7J6pZG4l| zs@`UJ``qz*XISBVtFBbz48OE=CGPgH`loorS2|g0$ienb&+QLA66vVA2+X`#+MMc_ zoyPfie`P^V+ugxZw@0g8>bHH@decvBKqWlLM4?ld3(0jAIJ5rrFU52{@@`tGUV#?E zkM)P70+*gWL}*xGmv`juos)FrfwGv&GdgXBuZcWMI63(>CiKY|!~SQ}+9+UX1cv#0 zyH04HUcJS2I!VrrPU-!PcFrfoi^X#td!nc-SFyMqIr+5oGf&%-m801SBJUbbl7;r* zr~fv&knL3E=2PQ}cunQQ0s(CbI%@pw#%mu}rRN+qcuMMNwUNYUJr zeCNIk;DZIfujy3DPHYz*1C7=ka!G2Qss_l+!PPn<5{4n5B{yDWH5*X$w#9Gt6!&Z2 z-RdphH>Y)9mhc=zk-+J9D1 zvsCpnim4x|OZlbO5#F8XnEdD$hacST&*`biHJQo*CA}-K9V{+JBL{?D!&R}y*A}S%zjr zbx4nq-*s1Sc&{ejaj?@Wy!!Ip&4NHw%oqn`Y1= zp12>@bM5XJjw*>0W`7-6-ZKTp?sF#YyrZ`@&vGj?xN>VJDUk0ps-~ImJ+17{*`G;x zH9sUr(mi{gH%U^;L6j=<))MB5*FpU}Ludpi)j*Q|xJ<$|9lgj<AIOt96(_6cxexmS!0Sjy=z8BG$4% z*)NZ4KbO@pQ3Z*k)1_QBEdijAw==k}cf!V|l#V5lFuWr?d3F0GbCp7bw~ z5we+G2V2Rt7B!GntTt-`@H0uMZ`?QXe>^|5 zy>^14R^rpnDq8QA#xeD8GINI5u1g=xefb#T2^R7!TMKvscq=;+Z=J0z7sR7&KHX8p z-26bzsJ%*jSaELPk`IM{$CWWaSOaTsM>M#UyF1R-h|_uQejeUoVvH(G@s%awjKWD~ zL91N#?MYLwrPGPePlh04uu^^{#73jV9cWTj5e{XUj!p(&%Dv7)#vD5`**W=qxj%OEaeb_4UwvCJl zc^n3x2=QXE?yLSdFDbHXA59!yuQTxCZVO#vOr;m(4aj== zIL5E8V`h}+WZloP^X`TlghwCjlphf5RA;MF1wA#>kkzCV%`{b2=$>nhyk51kbV(~t zCau5O0rltLoYC4rK2XsE^P}ZrNzrn9Zz}bSF1XO?&#ZmC((&sC(k45iBa*;^wn`RmK&Lw8cjFyhW&jTn zYr0!zfRWvY8{b*6*F;X%E8QOL%rGM-Tp;^&1}~GR^W1e~*S-U!OBzwBq^O>|=5RA{4c(3Hb#P{PcyvP? zYiS2sIo7@9o@>o}+D4}T3FA#!T`cx$YF)n5ly6wGml2ORXiYAR_F9@`7AxPc z51OQ#Ji0sy6%&JM(sR{w-5GCZFP+iRQ%mCXeNo{=qwogm)?0>oxEqmuYso{J0!?=^z(yjD@6#BLLhP(|s1 zPHA$|DsrYfVj1i1?a96?#p3Db6?CA6ef16qevQ%Bu;Rb2EuSq%#Oit?!HJEC)x0qx z=vLnB`0Jj>)`>ETbd}1RB+~#`^5%$YZW*&)!kS48FgsEi=zcblDNa)p>PR7>+5VYa zNSO3T^pno%bQZREB%hPQ`LWd#lJDhBhKuNLqH#4D)9g|eVmE9$QWY!$Nw%o{I*KXu zXEbKnIQI75-+UNfEU!@+{%KQH)eCBl;;j>A0smUvCqD`iVf8!h&;5JwzrSwiu=dP_ zz3=A`{vJ#dJ$oV$-6t^0v=J^G-2E&Q-LUb?m{j!7$4vaG z1Pnb?zqcN#j6gMs*}O%V$YMG$D@xt{yVqZ3&WyLZ2jEceLan@F&fQ0x@~2ezS37u> zopQ@QPE5rE9U^S|J(bu{Cp{~b$fRA*kfFHEcQ94ktqQ$o1dXxyZSN~sg#J7PT+LFv z3&!yTmPBmhvRkI(AWy(HQajK084KieNjW#mc075SE}!hg^Gk7^FK8RR5YR)c@@_o-kQ6i+a>k8zihN~#tu+(3^{TsFLg{=P~9VZ)7~k} z@w=Bxqsmnw;a>mAtF~yQg=y=ep!s=GFNC_oEjOvC=$B9a<4=_eT9p2)H%~4>z2>aC z3&EZGHwkNFn*N)F{ER%#bgFOna*QPE+|uqm<@|_8O!4v;fli#vw>fCkY?NnxxMso6 z?Z5mtxxEX*&Wv;j#FJ%zoJc@*KxbGe>?&irSH^O@TL)}*g=h|G;#?Z*%bPOjdYpyM zc$#3oSFLh!;^g0O{0n*AoqF{j^*1|dT##5z^>Fm=!TMJOCTyJgalTIUj3zIhB$vboxtQ2?ST-*+vAKi)7Lzp41H3(7vl^BicXAXw17Q7fKmZq zIS3yUDwA|s(hr=+AW;HA?~lK<#jBpoyxrcFjeaFwvI3p2{vFNst3?sw=rE@Rw)0;~ zBOf?}lVSMUN^J+78Tg_4*Rw2~uoi3Avxk}YDllx;k1t&Ij zK;>&}@D=E7BhOOAg+*f}8Cms<9apsDn^bfxGcm@l%RCPzet$j$KFSZWnH{B+sG~xPfZ66rrb50K-p6A%kla| z8Yei~WuO+XE@qtth96xe{;8qNvIc}5=7Fs?g9XiNi^GFT(frL>+PAFxbIl9NwVdk2 zp}Uj2nJ^H|R%$EB{hA*;Q>K+YZT{grW~E)@dUI_jo~rpv%||-cdaN3$Mx5olzeBhm z7Q3Wl2YFhBO+}%(cQ7~eG|ec1)SEy4rN%&8{CM7dH?l;cBfgF&h1rSqSz74yeT|#4 zw`s`5dDCN5YcAN%WVDlqkDX+%O2bPS`0@yf$^Ak!)Vv&AOO9bY0+qfIO${TA9zGQG zZZlL<8EgnfaG4nK-!`VMQ?cPWyJ^d#XdZ=OGq zc*pTkyP};g)4W~Mu+Th+M-okVL`cUy)Vb=pRJu8!7B!3M%ZVHQqD?80d7w^L?6Kpl z8#e{gs$9+bwI%O04HeCaF0qV{0sMvMO5Zj(eaDGoDuyP;M*CjUqVBqvC{HnSk4KjD z58AhFuM;bMDM%80FMe0VJ7TGe6ZSN^>@mfWvdRo`q31ZTq(jeSveWVvkw!3wZZ?MW z^Jz}(%n;W5+7M9EX)LyARZTte%HQ0r!C04Du#18{QM9t?>&bdW4~!p;;+q7uO?+s$ zWQ{f3UYm6uw~)0y(b*9u9zLjX<{Sq#iNJPdZ*Lh#R8+>9rMo96bsSat@9i;%p3(8@ zdc%)Rbx|pQvL`%z-iLw!w#zm#S=r?ThYtm?qKZA3C~v!cz6He^1?*;cK=jP3geB9t%_>xFCfCS6M%li7V?e!@RBl10U zN(W7Pbzh!#b!m>fYCrK+U2Y|zst#LA#%pMmeA!!TwS}B_7@0m(alDX*fUxZJ4Uba# zcPw=*%O2oIzPkK9Li8A9-K3aoS5%*~GldniBI>Mt*rnq$z(7CsPjH_k8WIvUC57?9Q zj=%Y=x|VP6byvA)t_FRNKR{o?`ou-`f-vJj_xFJrHS$Ml#{>eGaYX&oSoF~SMGsRr zju8R*CjPr~MgfSF4h~PcU|Bs4BbiR0Qh*$OR{Pdx>68WTbZ9WFH~;A28rgglpY8Fx zp4pl1-6xyHu;*Xy`A{z3R8l^|aA^{XG`x6FOjfunUUbj%TO0N6tQ)curG_@)-$tCV zl^Pnzkcac`HgpTj58GuZlPsP||D_}W6U-@@V zNvF`xmWZ77T=yo_WB=1=ydJ0Di0DNOg$&Ewr$E@n2c0AAv1IvBhBM{N|MtnxUsSY_ z$-7x1T^amHgs8e%aaspyBpxx+k>>^M-WP3-xbe6zfP5y>I;-eAqDsQr4)!2gftAny z@#_0zIygT;!c{+xTA&rgsR~3$>(Zps7ESm@=#gC~5YQJ51`6%% zRX{lrVtV=T2@qm#9GczYN{NNZH5@?|yf%GpT9-OwUo$BVyd7g8K-#WcAI1Ek1x??8 z<6orSuWMgw-ji)e*L|f9*w!?|@-h;x?P;dBdM1YD5L7ne2`XYL+TwF3Z78VSY#OMc zcMA&i*0$25mvHj(qp7SFo<<-37@K(-b`m-~uyuapuiiR7gXRP^8 z(EHe@^ju*$YRjw2qF;zf5{K~lMEnVqMI%kH#~?>)?nqL!y@NC<(lj$7KrQz2Cdhqg zV%}Kbp%=!X5H-d>dEN+V1IdX|5+Akjbj^%`S!34l zPb?;6LD49B^6Mh(qUTOuQF(tpeBTPXS_Sei40tj@8#h?Cc}OEy^C}E~lsKLTV!Ry> zM03zz=25}|`W1Jo%OwA-d3r~zUFSL1TYcwuB&-9iKc5nF=6K2p9S=3V^u%2{qa50R z;S0n-SXkMW?>Rq&LNAhxNg+}poOLUu5on2S;hp(B=Z3qkz}-7_LKS}SmKZvaWq_rW z6q{=mumc43(0iA@5M%EssR+y5llTcuwo_Xi?JY=GeC15gzQWu_R`D;^)!|2@@8oD7 zS0k}+vM#g=xPglI4_j_dfwHZTIYA_=Y=3+1o4 z=@Wghzq{o;UJCjbfyDA%SRKNZR(==?Wt6j#?=N=BIGB5ePm=3VAbH36x$F-876jO< z39EZ_%+ZSJICo23*LNjkRzJtY#~}Va!Pv2H)%rnXszZ^B4{3I6{rT}~@8^g)fUso4 z@+T6^x%Q)Fjyvi>UFi1G?Cp4V9c-OSxzjJ`>U|!i$_zP+*`enHH$uK5cC81S+Mx`M zm<5$hSCg4R_70AL3lIpHL>U&cp+Tj}W7T!F6N^#LGpQjuxY`KRXA|IC%ih#Kh#)D; zVDwCdiQ<7|xBi14S1$J5$iQ@}m~fAuT*_mLe577d}SzudgK`;ehy+PpPZC zQ#|f>F+R61%o!iV12105Z?gquA-ZZ%fA;(NAQD~AHlat0SaR~?a>`h(pC8;7Z*5j% zT|7ZJy`Qa}Z7__;q6-GhUDvje-tgmbU&~ z{5@H3QRPQcz-tK|5#>+@nfQSYEDz`@6y2pe3^8Zxk;JyUJ(rCLASHIvc6UY(_>tDi z!R0kCbZ?gaY;e?76h@3P^YIF3dtN>Mm6oEErC0Qn>IY5S zTIcQkrAt`)5hMc0JaBmW@SiUmFvKKE&B^0D^Tm1IgZl*O{)V+3XBhPh8~4#|{SCd4 zLyf9?Npb>4C+b!DYjTKyNK<4Z`vk{x0mKkP4c?EPTsR3Kij3~_O!gr@dV9UA&iZNbLE;3^2brFLItf?W4Lt8@V zkNERk#xp6+1e9NEv0kzg&PyXP#tvaZi(jggVnv4!cywCh^SMDl*@C?Ug&l_N<(8zP zMS`GRb-YK2JMyIMTi*%(F06eq8)>X&ef_PFXIgvefH1Y~1w})Oe)L4btl0puHFPca z%jbaWeGDz@&b@cy0H5IL4)qtqV+w1IvbWuq4`g;Ge3t)NoU><=6MIw^IT|#GQ$4!& z%9}n|!k_mnV}6dB%0Smpy$nPu^d+EA(ZLFhnn~?c|J!0Z?5?JjYGL_h;x3B>QgZP_ z$2{Snar=TMRi|ti2&6)Wp(V^Q5M!Sk=P~i@uVtb)wV^7{?<{ubEL~I6ef~-9Lw&SFW~Ys)^G5azE~1ET1l7 zU#N7Qec-&>rJsNTmJgb|jva4;);CS2CDJQG77!I<&mI473)s-6T4@t}1D>-*S_YOs z1w9NtkQASAlLQap)lAnQ+>Y9Ki0RFW`GGGl)DiEYvqm+G$0QKnFDh?<$fBlSUmsu= z{bvs?Jd>2K*cbhzC}9qKC98uaPy~r$<%hOsq@w8krGx#}gM1*72l%cbvHb@=XvlIe zg3L-LZ&g_qd)vM3Z+uEWD@+=yu zA+W5AI$7oWt|gX=i$g^;<+83vIr_itHb5wUq%*h$gDVS!=zt!frLU$|J)dU9;-CT? zSRh<1KiL0R!2q((YasN<$)umsV3#6BbZc2MC9BH(|F*`!eT9p+tR%|pag)IOou!iy z2LKT#-&}8YVac$6-h-bCo$)fn+RU;1ScKO8y4InndI56`k*?+6bu6p**?2+vH!zxX z8*<#usW?d^*j4uA2GKPnx7`ED|3TxxIh~!nH!SMkyX#X(I6$b%3jzY}I6Mc?*ja!6 zAC;P<{ZnrJsnONW44J1?dwqco>?&Il{kxHw$0$1DgEvJ$aC z-M?-XC^sElhmQU8@ozfO*$aRAY(sVnIwVR#RD4jy+HzCRw)ur2HF#`dWfQPFHk`#X z486=MXbETNo3h3z-2@w+aR(fN2?zIE?* zxuID0I)`1l-&P_C2QVZt_dc88byl4g0L!EZY7uzXF1jM|uj~o1y9*do))_A@7#LZF z$kt!8j!B|1hjEA0#}EXO0hm0;4vduI>Qu8W`j)+vBK-j3H&oy^3IFmN`JD_zzp8rH z_QZvm^o%TSWUd9|t43|3OE9~FpuJtlp!?;q&~++W@6n}+&FT66UeKvwU)}L!rfwlW zq=~!(eGq{Q1t5B-gSsHu`wu4T#{Y<*1duQ6Xc6fDx5X#!g(tgW2wWFR$y2{w*@jWb@k%p+Uc7ar|Y&d25Xyh&mZT7?~#u zKmt`_AFyE+3KIEu63_78jaT_w&R%{66IaNr)4?0AQQqsy2OZ@pdnwQ+U83yyLa+5- zP6NHJO@*25&5peZ*r}M{$@dGEks#+E+`9{EOUl@@xD%kV?2Yf#o#Lz=GCwY=tmEg+SCUC|4SmqFf=H zIy&&s9HuJJiQny;tKcXcGWp=K(LYmqA%p>b|MDv|M>pd0A&hx?xWyvVBgg~+63NO3 zuc7E25Fwvk)(ya{tI&PNi;*vL-%^5!i{>2Ts`Cy6xlsRB_rWVOGmQkO3vGg$YQ{zJ0r--_)0g+$MG;5N^Acyn^BEL~6;g;~!_C1RI((8JCiOur!v7W2d{F_WsMAucQQRQB zFR?HO5Gk>gs9+@Gx|S1O0ma$Sd0LrYkZVACJ0<}tzk-L!)2*>|Y7U>E5^`?AHds#8 zht)olBSpQKh4RD#u>Mp%bL>f7!g-MC0BAnAh549O%WfYJ{I474DZ6z%bT`XechQn@E9sEOnv};ImE5b*Dw| zb9Xfyly*?y1N_HY@KIllVJO3yjzbei2n3UD5->Z`9BlPyw!>G!=sI8?@E`!( zyl^)`O%nyszb`_$N9N`onCG%}DS{Fs^#(pY6OD*#mm#5&j0(=r*4R-k9CfiK_oxEUTSGHv#6m$~xI)>FdjVN*-!SWI*x?f!!bO~+7 zD!h|HR#iW8?gK;)!g-g)yUoi9VQ~NQ1Srq>@E$6vCjN zsytH;0LDI*V{=1AJmD3>;JFC5@LIj*Ga$o$=2yk31RYav(r@s)|G6*ZdlE&4C{q_E zwtEfSI40W=ebS>Wdyvb;$7m`1{bosb^4bEMelo|N^cy|7=88L)a}7#eS-KcdWbKg0 zcf%_*US1Ai^@bqa65*LbcGu*{R70LRhI&qh$~6Hi0LBIhxzX<}oberplU^yQWPQj$ z2*PuLevHP^CLOmY&+@j=1NWYLmXCjZL9{N2lAHzX6*^tkY{9b@^mkxt5Nt2}c+$jq zulY3yuhF2D=ue7z@7C0ai}Mjra)Z1+2C>PTQK# zu!9gb#P!n1`arhQ1Z~5M1!R36C7v}c6ppGe<=`Hj80raZlgGB>D&&9v%p1wk$2FOI8ix zvM77i3E3Yz7#;&NxF6KQ#FsV^$#17#bA8whw-`}&x?lziI3NJt_zhM#s9>aXyAUAB z$5=$j3&Og$3)--myI-=sz=n|;VZia>b&IC+pc)!rzc>~R8#ukiO^9?W+x7r5U zqzT5bY?*REn2FC=?G{_S-qWR+P;Z?{qkob@a%jO>ab`90KzhiML+xU*1(k| zaiR~D9+(v0)c0u(%*W;R(s7gXS<`VhK*)p}^Smc@2&4@g2aj02k0Db}2rA>y%+Zke z^N$vQy3>Fw{f0BJRUhfV1y=XSYr)s1H2ztfjYPendgLH4xwlY=H3dzD*`CF zJ_N}T(q8~0g+ZBnNdcxvGl9I;G4E_D$m|OyBJCbPKy(=d!LDU*Hi+_t63LJ$Qd@jd zS{u94buC!WbE?Dj(5taN5(F2~b+7?_lLS{vfN9~db99V?WX<&oT@6B|=q4+I9c*|7 z*0i)I!)d-RRKW-pkH8_IC@3_kk$Ts|BqKUbyy)R{0eP+L%k#$RTA5Utg}5Pj>8RwI z_4E@FJ$$`-rd0|cVai)>K#iR|Jc!f88QR$e0y0pJ5Ix?4vDU%pcu-B(Ok^JOfxeF+ zQw|Ks;(7Rk4N05B25I0NKTU~PFj|5gTp&4?UAKT8A_9~&HZH0)pP>!^{r5GmH%SUc zJeDo6;>YVUR5o7o78&dYq*18(T)y3IYUuD5{@9`W)zkw@I=F!)W(o=eTUU z53q02wP3%8LQ6L9+0ohF9$i5%joqXh`hPThk2W(+TwxC^~=C1?@N2c09Y z_4=gBbWOQggXvmOF0LSSA$CbmU`L5iW|T*pEd&_^3PPKj&xqtx!vFpNCZ#{XRLbw9 zaRbpAtfi9QfNQ8``(tN9TmFi`Ui z{I*gz&mxHC-eVZXmMV(_ljr}$i|O#0IzGm7xL$AVub?;Hn6d^Q@RGkHC<6zgvmtA^ z{k-pOlaO;zNzB#fWCFz2tu}|N^B_R4hsrDU9?ndVKrp?odj}Fs*H8vWd+ZW!*Kid3ll1SnFBVM?;MEl0 zN|-qRI)>=3@JjXB08V8qb16>fAswX0GG&72VEIf)*QMm;?=X)EP$ENF(=PtG9d}H;}fI+m74($DVo!>25KhUT- z%ma)3xswjzQV^>6jUBTY9eIQcqR@msk}U!jKORSFx=fs8F+|N-_6*nSJTxc0u5Wz5 z4}4^S^rwB1J0QKaebEPEdUXa02T-2TUQCU3TfF%Y@%~MbrLU_^p!ZaQHrds25_%>W zW})}mc(h{seG`%zetr=DkIvLa!1H}CNv|g;7#-r}0-Jkv7HPoB@FG>n!eF6go(75q z^nfU1Ftv%Az=g2ILpPEG0u#&*C}~KXs)rLVj|YMFt+VEbA%I1_Q2<-1rF3KDM?*-c zx@u&MrFjxaAE~lc7@Ykq5C%xNdEVUXbUJRq{c%YiyP+Gp)C_lk=url(x9!bEXfbrS z%InM@;3I9Bu@E~}K3hw8Xy?mU?f{Nkz{Y^Cv_JGbJ!3j9l9o#Lj<5?#^#M$CctWoJgx?{EcuXYBM5#Kk<9*UA z>Y%WP>?R=DQUE3c9*b_H$J4>7gN`0D#7U2@fl(63Oc##Og6KZ4Yzd>MtK}J)spD)^f4Gl0Q zoQH`DWbzvj695J}$1CZ$&cL?}^07gPMTERiq@b$3?JW3y$LDilAVp_30<@ZM&g`k6 zzYak4X#lJD9;8SK0{vvA3%Z1#pdSHm<%S#&5Cip`QIpyYTDS|bSn^uv#5uZ!WdxX} zi{jg>G38u0{SIgkt|TE5tRv?9QejBQ4q$ae_!x3=!{Nwkzc_gwxXk(YsdGbFgE`=i70!_nD7D){Qzz~s zJz8V{8VoRR<*NE1asy1tMk&5cq9^+M0JI6wlSms%i(37{ zusFbkG{9HCi>O{mI09ZpdKXYc92NlvUmprz<6McZs0B;ZLLCtl2Sc&xfHLn2EdpU6X4UhBuU1S`?UjrutSlCS59%%5uR$hmp zTVxyLPqSvju~D7*1D%p*!GbWNgfLysNUhDCqF@yD6#fH<<#ros7t{q=bXJ|oB=8V7 zj!K6^a=#Qq*1SS1?2 zp{!|Y;()#;iG&T#t8tr)zzZ_A;`+?eQ{~$ZI)^CdoW& zJ_DoRwlroc9bS>F5@W^Yoi<98s5${8XJ4oZ2bLhj}ut;2=7&5oGucz#mMh zfPbXf*mG-B?|?@%|9B;4mwY>x762Q3HFy{Ri(5?BDj5ImBVrpi(g*$)n*@4DvFV_6 z3@J57#4h-25ZbKGv^%pRMDNol>A(&Yklh6$Ck+?h<>V$g;vq19>Qx;VV2Ws$Yj_;h z>A=-BTD0d35T2p>W*RR-QIup&12QFq2nR!ygR9oV#eCrvs8D z?Jy7BfD?kpG^wFWlX-#9N~2PPx^wGl;?++u-T?jzbviP*0m|=Rmq)mW<*)x8QiT*r zHVE9veDnUV?hGlFMBoga%|Hx6@j>{OCQhep2%uo#zwv2Ik#V? zeHtEv{ucNE#yJC&P`Ch}OI&(d^J9CYbRNUaFiG z?h80!fSL>yvU>!GE(?ScZ)j2v!tiKY{S;j2q($Ayuz6R5dN9{KP~ZOWiVF>#&Un|% z!XmI)I6M>!4B7*#5x2VIn*|!x{?jWkJWCLRg(48s`TZY0Fdn~s2f0h3tQ9!qvuip~ zPdupE9DZb2y>uKUuXnU~wN^mOP$`__#_7&B91Zru+Vpp5R-vUWAs1U9H@iSSmJrK; zHJ^9@ms~+?M!}(jL`7boh*HNIshPy6XW|1np=i)>z5#{cEdOhYE)Y$0S^!!B@QKWk zQubIcC_%lgUKn=_C2<~eIE(*_0Es;oO_67SyOQ)J;&+4?3&46e2yi#F*l;>69(cFh z_<=B2Eka8t;a(%SY2$Fx)g8#Dge$-(sZIoJf`mJ%qw0ddH}8Hodh zil9z6hf;rH8Y-p5cOFrN#h6iWUT#jN$pS6RajI}H|)iSWQ2 zV1D5IG1Ps80R{-t(IMXms(DK`@0%AeNTZ4dgXvBu4fgA=fCF&0(awxFO*}O4v z9`vyKO4m%xJAT_JL`~Yq$59qwI&Vjcl7}IYmjr;nDG#ul}Mh28nmegW?-43I_xwVq@rbsY8dsBn`Pfsr`B$Mc_S zo51$xO?Jd}V!5GC=>&#Gn!8tVvuY-pP5`2ZG6jBxCFY;CEQXiU_O(T77IZU~N+$DB+7g=@Tbbzclh%(66_=3EK+*g~OaVKGBILNgT$(Im$^@_O7q-zTc z0SvJ;p-^hb6=X)3M{ZE8!5kz(zQSjd8XKl-967IQb0kl=RSjzWHm%^ zL@(45p`MEZu1Q3$H|lp{gxd4e%~jBH^~LG~K?{zK za&UaxKj|O_l6+4Z((qTT6jF^`jsj(cZLjbANU6|_5~2cfU7InsUo=%EWH&&9_eBGP z$3Me?oIxhvMa06DlPAuPDgVXQAwG~JfFDLYbqZ=hBEc%a`GYlcI@PqV6pU8l@9=}n zj0aE^d;kQq&(@A`Uz7@z^Zy{MThf#V4nMy4D;W8c(w!2_Lxx*we!+0jbr4P~#pj($ z4lsrO1#f4{ZPK(GT3LEGFZN*X8xsFHW(MjQgwqozy8|UIH#iSK2`7=k zN@iB?8n6zeRDhVMtV)kZ;ado~LDk`8oy8G>?S)$at@M%)Kj1ne;+ugmC_J!E4Glwm zoLFOoQ-eN4p<6plc2i1lr3RQnV~!8(IC0{9i1J^AMK}f56+lRaOZSZ1+B3-RBBPAT zF7X;Tfs~rmohBZ>!W<){i89;BZ8w*)fo5J2yo~w|Zg&CLXI;bB%DOo009#V#v$_-aBIzA`&*a= zy|Q1AL;Dx5FFyzRc9@gQ5V;fY3`C1Mzi`MeZj?m(Y((rmo`m#b zsE_NxeJzJIN;|0N?J~R|ku%vaKL#&rvS{<(wfO#v<|}31!%Rg@sOIoVa9@nn9F%@%9Fu`F z8g}aKXCOvOp5I!i_eTYX9$eS+bPop5EqXrP<8Sf=GnFvxq6&u|L4PPYt9KGD6m;)GO06S1y%VZr)vKE|Kea1B16n)&Is)kfBwY?OphTu!bq8a~pUe%Ar>IUJG5aq% zouwN2O0XND4v@uU>lN7D-N|kR$*TeXRhfe1Gli5GJi;MGxUoN(?O~VeWIxg@Vr91!#LB_`lhzkD!F$*CZIZ-!xbO*6w33y8&aDX@877xXG*837{ zADj&`{J%Yz_VhzlNNH?>Fd%n99@fGLnr<)!Kp27+jU05H4oXKbso@C=-G9~>F>inF zngC}a)O5nRo&Y?^1{Of-YoI9NhNF^Cxm@X9yne-=Oyd6B2IZoi{4IDp_E1Qe!+!_? zE|St0DCC|&G};6)2i>RhmNN1oxxMeGDsu_)y3mVHcn$W!0|$J1Bj~#NP3nil-#Kc5 z&SJsIgj=<6mcY61yYmHDuei250v-&jm4*VCurQFw5WtY^qq>k9J*eNv`>Pzm7=9?i zJ?f&MJ}LcGP${uxz^dh!=thyEWu*3j1s0%H3xQQE$mAy~C?7a9kn37JrnstV=Ji#yKTA4KIteu6rBmJvrCvWIryp z-tyyT)YpQ`0v>f4?aX$4n%&$lb4H%}dRWz;rMVg4if0E@_V!CC-a(m{qNH;jgu#T& zH-z;dgB;zwnXac049*H|GgD@6h~o|i*|9^%_%KKVAST#GH32BA1&FV`|2~C!R06Xc zIRoH4g*X02?GE8wHjkNST5jbsZ@Jz}FgFYV=#{n%^($&Xy#6L68NRm>Rv*4s^}L8N z5@Is&MB7!tio)^v!E?*yT}xYD?@p$v+O4li7pdXYuFq_n+aSpD>R<+*>fT;Cde38| zq^EH-^^mW$UfK~tQ!m5EREXKN-GAQ-kOgNo%b2-Ns|IYn$N&3BANCz*2=aKVg$KYJ+?lK7iR$!-vTv?mpqyVYZfbI1tF>;&^S5m-{KENP!gePWrW_TQ$r*g zH0|A2^MGp}m#hIDuBGzFR`KamI+gA& zzb@{?0NsRPg4}0v+c-!P_>n7Y$23q0MoZf)KuBv2FTj}lT?$ zmZbVD(Gn;}(pYlA3YXoby^KZJhvwlK@SbUtT%AaDHAV03d5>9e8z2{UfU{AXF^h~P zpk9cjt^Ng7C1+){ZAt%|{F@-0{|nwd*brNG(c!7Uw`1)+gUE-gLr~?UjUyiw$RH3l z3w`l>kphO{%tfgmrsR`QQWsGwG{2k$KbmjIy5}LXVnha!@Xmt;NO(dHgkgAtfv|+? zHAx-59&zFzvgrT>&$01HiT2e*DX$roznb>0Ro6ft-0g}#AgULfwC7u&y*pjrSN{S` zI3QqRAl@KiC#Jv1V&M6>9lSb$yp3led&GuCg6mENWZ&N)XF8z~V>(;4W`}I8(i&!= z=yS!HwI#`BfZ)C=fknnmvveLjcKUVMkVnd6DQBQYA?~W3F7*$(ZUzE=k0mpX^I3Hk=qLC*qPz_v@XUW5gut{8O0FGE*@Vp1h zfG(!d^7}~rSgX%QksI(k;ocX_g77=k&C~%KY0#p6$Py`k6T-ZDS!_?~0zi5p>=KYS z;P<0GQ+-Stl>bP`q)X)izWSWXS`qrfDp0vZYozB0W`J29k8$%wSXSv>NzrwrjUt{DV|zeb?l z3Lc%z0S_R3DDIKKdO{Q$fJ_DU4^lJj_)F$IYyl`xD2ZJ`*Ci;V$GioglF633y8`JJ ze5>YNxNJaaV9y}i8fR+Mb2A>A=suN&I8JVCQNGrbpddN}??H`JUcc>(B@`Csh7llj zHoq7(g-~$8%sjfsy{sU+fS&luz8669goM%F7KM`%inzXON5GOzwL@96{uz3wP?ajb zQ|x%yp@JDmBEgf3I090OlK_q^_r(*3L1&nD5%3~ZWUwH%`d(^gf_sze8{`f}p&Wu; zSRydyzxTquQDvTksOy@rC7Wv#E}@)@2UHs<1;F?KEWQ0wCo?3Y60)17lJa7}rF7Xq zgL{a%;V1}M(DgIVg9lMetX;XfgD(9jJU=jOfaeGLu5hWy3~#uCLmS_=!-aFvL+yotLkD%U!9mzN z@giXCmN{~X`uBa`6T?%1IwQ_)A~~=FdxzbnOUqSinL9I14v;4^Aazk`&J*VKt!DfA zhO2?>u6(FA`xnSTIzge$lqFkF(n6-I{CoO0QmQK zAq5FwBVReU4+8xVG=CR!_%713iS!sgH$6`-2cyeU7|r;zyU8y)9QLU6{=0on~;f4$c}d+%Ef<_6TkJTM9& znE9Jw1ml11!Cwtn!Td|iwov~>FMI|Z^%UwwW_OrD4M@Lr%*5Jc%2d-h`#UIG7PJKk~qfn?N@9TMnBJh=c= zHf=@cN-~xkLLy``YJB>d$hQk>m|yeKzaB4`Li~aRQ_!LeUd0+CTFfr--)VFyG&*&{ zIdSXb9oObh;By0j(_z!i%;haCgQYhK&<4G?d0?Xm`yHSWdyvRS55s(iAf;Y8JX`r_ zn8bTX0v;RP1l>ka{KYAM@Qqh$AKI%K347vTa(yDO!+l1u?K{UF+QA{6NQvyk@xiMi z^AqCZrWWuSC}B@cZtco5=mpX$69-#>DfXQxy$`H*^x<$&cf~R~gl!HOACj0>7VyM(}j8 zQauHbk`hT9&{aM8IvV;O!pTco-cO)~%JXQyS1#Wh2sm?iw|m6 zM$(8ZYF%(hK77SLs@4tC9}S3{6#AL6>HIOka=1SPZ@~j73`wTr=nB*Jh6A)MlKkof zZZ_$PHE_yq|Uc8MRLZ0!L){^B8p zZeIJBpSD42Fx*#p9n{)mlMXUW5H}3=huY)ceCOU7Ca`%Cz9o9#xABZ~o>^Qwi?tv0 z)y*IfN|>+vxm^7VX#qrdbaM-(Gkr0rb9xZ7_uf{x4|?*7uFF3zMa5$g8Utb=y^jV- zR4gj>X6Ln$mpE*SzQae+k>F8HXu2c}W+9|WIM+r703<5JzX#Y;FU}7`4TPb+EbFl2 ztN&iwoNa;B+Khk`)T%K^>9R>mK)P(6lUKqIr%hfzoIK6Qc24@XZv&(s4+U;tLD2;C zbgG^oA%G+6q!{=Hmhr=wEwGUdszU8cKmJ~rur_VBA0>LIF9Y&zzRY;n7}Qcqsr5jZ z3Rye~E-ZE{<2k<3?W;h_*{T4Ts<&tO(AjJLgDcucXg8n3yBFViS^n`H5|^eR^A$u* z9SD^}_F3v+WVgrOl}xf61Fl5XeI9j_OdIgGtAy?wl<0hG3Y9#NeNvGenDMiaU%RAB z(=$PSTlJ-LC2DWjMTZ-4cVL^;Kf354WKv6w7*{8dQ%47KxO1It0vK)|avv>Ms&9XG zUAcUz{=oh7E|6TsLK<cQ$x)1#52O1U?pk7QE zWALJ>ReXox3DK}tXQqkwsU9vlwa2hDKfc9hk zc8WK88`eyONzC#AVwd9;LbnBx0OqgUpyR~k+mfNjGyK9_0;bub@;s-cPn`S<9Nd9^={=p zE2A(3@@YNlD(2p)aZ=~oC5XQx)~wvoQeN&I*9C=gf$}d=jb1B7ekDbel7SQKX%J1` z2-^3?UOhpWbFLd`OJ zzkWW&g&PhT)m5#$L@}U+o8jn4SaCC6y%Dn>y=c>rFE+&BnsMM^{`BK4n0yLbE zpwM34&vFtM_ItO5@sV@77&wc2gM9tu&I{AA1E`?kY4AWR{p?L4{y?mMmf#$m)aE?C zlhrY*QL3pTbX=?IJ1L{#mJA_!CTxdr$S1>8u&Tc1&!X<9bZ<6BxQOYO*jOg`Y`7J{ z5o^A_Yt{vu@`x3`rfS$aqIuX2;xkNn07(1Ic=v<`%2S8I{1(pzUZL(o+XX^K{j$L! z-#_Hd3sKRQ-vlJMq zrvVvM`#AxOUJQ=y@FCd;j7?kN)(?c*{~c6zlPXbv*^GDuVBROc-vv`~8_0spO2}3@ zA&0I3!I1%Z-uYo9(B!p_D}ccE6u5rT-Ts12bAVPq1uW~!T7+)fsNWnIoy#eW8nFG@ zh19kMq-Uu;u`idE+f{;>uJ%K9%Hi}bT!VTyzk^!BcL#pKqCN)cBfooFO@ z2K)WlT7_gD+v8a@KU!#;-~pH`7SAYg6{PTJP4GE2)~%CH^=M9 z=@4HXHQ_jfgI14!ZOT4MH}H~{Hw$_bu&d=g(Ovn517Y`2>&+G5$<9Z<0czZQ->FL< zJyhwJ-lk25m|#47{! zo5GE?czm}I{<963vMaUmXCsgF`24ru5Cr>)D>s0SBdry!BwLyzat`p2k~`yIN~GcO zQt7JTp453RmHtsV_Cq-ENkO+!{o|eS#x(?RtsyDnP#mCwB?_5TA@0Y-nb?@oGzg^b7*fxknLYSbPeKQf2b>;!o*7 z$^!*A2Gqf#WYFlo&nrou&@1w=eP!bfyZ#4~-YZA~?6-!TuKKZU|DW6m5)@pqT59)$ zb8{*qp`gp_`T(f!fz0cuiR=b1RK4Hl-79)t+{$BJg26IBy<7} zzy^>Xzb~QuXj)!Ls`&GDV?Y)1>0NP9Y)Q@GkTGOiTbR6#sNFN(Mt_3hBm2Kofxa}6 zQpkj&fu?CcoqsV>yeUQ41w;(yfGaF7$QDaMMJYU%`ap^w8I!|1o;kQURB5EB)9yT! zYg9?j&uNc&0_cPLPQ~cY9-f*;zjtqC;69P&`W|QM0I(DvjvFaAsL%ITu zf>j)kv562bz*ZaL#oEC$$9Ve*Rj!cV+uoY&FUst<%#458xP?ZBz#Zn9PaiYo0!3SY zrQQcrumRT|u^?nCnzh@SS=u2E+=b1-k0(JYsV5VmD@bmT;9d26c~noTVfz=DpaijV zS){**w35Di|00K#QOU*(9J5Q@f5K^FW+liwknb&=z8BU`2;tRIS@nO;mVo^$vNfLv z&B5`i+y=|3C_Q&xJe?YYiqV7%7?o4LC>J%2uOy)+4^)e*D=G$f#f^1kM;zTqEdQPd zNl!mVBI@WXpuc)rUxf_1&Vxn^!*HVa9nw30f&C_CS`U zPC91&1#jf7d@M|~))UsTJHE-&s2xE&5~aEaar%j0QlU!dPH;bf(D~bmZ`?>$DVdnnB#%_G6I>EA6j z+AT<8^IrLMKk0b`heulF@y$w#MoY$H=kJ=I#^?hO18Dfo@?7c;RP&2bNS!v9EFt}F zX>jDAg2HJR4XDqc2Vey`Qhj?~nzk<#G#J9WaWL{{s+A4Y*D0AEy_rLG5Iqb zb|oIONRB^-yvC5yKz|aCKRX4e3lu?*#iod<>TpOqRh0rC+2=gn(=1h;P!73Rz%RZ= zz`72fMg=1H&VSm;J9S4I=5fuC3LqTUM~(dzG+%f_knTB59fQP(ZNBl?gT8|X1z}RF z1c4hLWjdj_4ym?SA3~vH>qFIIDHvjh;PvIz1=<4upIf#iIuBH2jDrBH#bq^#@ZZ#HCkR4 zU$Y41yZ|!AU^nvst>NBbAHdFRlN2Wb-+he$C5NJWTp!Q<73kW_0eT=Lta@`2C(}-< zLdcoi4*kawp~4j&kPmK8lj?e>rL z(|YB${{xa$e1Ih^ncA-k z@#XIof~wwR16OQ4(Uy!xURhpz4hbJwh!^Jjm?z03W3R)$cR3Y(#kJq8yGJ*_7M!ZPmb?wMQiw zf&C=ek5Tj^#t8z&W`Xl-uSK%uwartl>NM8jCmPHM!tK+^mHSY{{pW`y!r2gw)Z~yA zh*9o9K}r14AiZG%h^mOsMg1MT@AX{xD2pRKpn{%9`AQanix-H8O@7aA-)_JxhAbI^ z_w}4X_^vDz;Dzzn9_UB@dU2u-G%IM9(SnKSMJ{rePR$K)y;rV#4X)s zckQbQyG8gKDW}E#1w^yQB`cL&Ncr2+VaV1$ulXR2KTOUt{b>^F3zN@U2KIiLg!LwM z*04bfGGJ7>XF+%Ue*VDj6aT)@$j;LXAg4}~)Xf5J!%W2Y)PfXuqW5Z6p6b{{M|v>y z44c##vkd)vyXAgBlJ#xj(dPj7cYWlU4BgZg=cmm!je?*C&T2bD{{SeE5ukL2UtU&V zy1_A40vh^UYdp-mBd`qTEZj&=4IPb!{+GBq%>;)R@0Ra8lOSqtDXsKBB z-g%fyPR$jD6oTAq4Epfjt;lB|%~;nD`UDS2eP+D_jFjd94(|VTlofsJQT}8z2PZiTnYnP;ms3jGH?F#Ixe<_^LNhX=K!h8l ziZf&8fozWVHb)v%!bk%I(b(D#fgm;U`;ly2L=FxFWRjoAn2GuM-1)jNoL#3hEkJ&V$gITxlT* z@p2~S6$lPRC>lAVK9FLJffkVzc?38RcSKn#&)0p)N=sIhv_rCwN@998_--NO=KUtB zl?v!oD@qiGUJ;sVS&#Xov~o)*nM}O0thJYacEGw38i$=X=LD_DCraYuPgEwX2k6x z&gQ;MxLjfrjq-fTLp{{yHD-Y*9cZTW+SBk78XwGoAiNw{3uUbY5^jQ>6#4aaL?S@1 zUEG3)_L*UzpgO&8vL>UG48ImnWp<}6{Bp?>j5Mf#IGz2OwB<8IBZYU>zt-)cjy#Au z_|3eoL(@jE?YjX0m;o3$=pmisVbyGeeY0VZt{vVshtNT8eep)LGZ;>YPp~?)NAP(W z17csL8%D;V;dF+hSFjvv~Ud^G8q3TXX&5c{3hFy{oG*8 z3qMCcP|qxPgphuMSCSV`Y>yc{N+`v2A>i(nTf}wixY+;uf)4B<_Ms}bcEd#vzaviJXvb=1grP^rHL~^~DLzw6YE#OwI$CZ9hFaU+m zYlqI7UOv5yxu-Fz8H%g3ia&ie<`bKl^{zZxd}(dvkG&`MoaSiY=@4Vj?*Z(No%bGW z9WOvI;SagbAvq zP5UWz;%#K6kAM%5xd%(}M_fI=mZO;mlokrI$piE1y~hUStu%_MLS`pbHJ0Phkh6nx z*;Y?`@;=YOpn`66hAvetK#o|PSVuo7!t=3Wbt(re2SOdQC^Klp6Zmb#7c=1%VIn|F z2Sasj6TA$z@1BW)DLnNI$6Uu51(r|hKm$9hU9nS*r<3ph2~I}#BTtg|;=V#|&3eGc z6W*Z`L1IRdS&UV(iA4M#C|}O_^N5n< z4gF)p_A&K04-O^5goD#v8&1e3_@MU4+w-54mfNSOaaSEOBJg8C9hFmFIrfBQYFkvymKT)ImZ`YjU=|_~ z1ivu)toR`}9-D5zVKBPAKbmgBw@jtQF1naY0hIKB&-RwG{efd^SypoSu^j1d5sBu* zhAqxDYET!-Wws-=)_`PpCV?uuB}pb-=q#P~Z1LvASU~N>JNMpZGLj39Rl<s9 zv%$y%wd6C74ymyo#d&NcZeKcEBW1OEq$NX>fTGG*O`Sc5;z}?z%4tQrSvZEou_l1*!6^ zO96;{53q6P&WHqqg1pjUI&F)17u}9lG^snz;mrZp2P}cYt6KNP&ajvyU0Gq^wOhRj zlM%{pObokbxY!0%HP(kJ1>(g{7<6x<(QQXPi#ws>tKw~#^k;PVC3*1X05P$5J}oNa zV!Cxbq!Kn~QfhmkiNpj1E#qm2Iz2iB=>{LZw6rx^B&6kVI*(2fWqRPPp zYicDQ=f}oPYv(i$>0E6m7EuMyx^>sg28a*ff2vxux_ph(f2i1L>r$OF?!1YPKk)gP z`XuvAOiEysx}db!BE~An&rX-Wy)f$T+=n`YG8t|$AR$VM7(>RsvjY}lDV@JN30g@9 zBC_@#)3Gn;sU_yl0Xk!Ur~m`Kz6EA^j@u$qo9>QQcF>0OybxpAIsT@&jivNudlf!g zFm>@}R##ZoIz7MMk5G{z+Pt|IsAHQE+&I50XS-IlQ)G>vSS!3wX6tTwW?dThV})6D z3ywPe31xu}k{$044L->hpgg@E#%*<5Ys(%NQfst8LkpMoS|5z!Zmli@il^80Cm||a zyk8*iZI3={=&7vm0y^8*zGluvE2VWyiK`tXnJX{1NBahg|0@2gHkooh4x6N1^xh?e@QX;a)L(cBx!C;vFI3%I~8`;#7LJ#>O}@w8oCOCC=};=``+j8OygN&sZiWX!ldQ>6O1fQV3mhA# z{O9D~u{wu3vy^pI7LsRhVAfSsug-?OmEQZF6sf`;aSmX!s?$U}aYFWqi2J95Sm$m& zQPQO;0D;r^IpS))wI4^iTV8y7!tSiD@=Qyv*#uP$lQ!I4T=rv1f^C7SJVdX|5z+Aa;R!wnU6-oaJW&1cpmw zd>{>d15>cM`3^G#DJjQK=$r3SqT%ONY@;ojz3F0~{W1LF!Td+8gTKT(s}ywVwFlJN zHv*7HZDo zIHmLfc%!SNCrLp8TvsQg1VXg#gI4Wz*exzT0;9akm<740O(TAF%Coi0Er0T=6=QL0 zWY7l!qk`>foN7_R2WvButMUVeQGtNu&&i6M6CWatNwg{~i8itRm7w#mq#s(GkK|R+ zw!F5uVi{Be8QEi}l?$Kb3pxRQ%`I29_I~NX5ZdHmm28FU;L@}*Ipyg*F&yI?q;xet zwqLIGPFSk?^g5eWkz?Mm9V4jqCw*84-;-dGN?*{EHsMRrbclfSkY_YNSKEVDk^NfC zHd(^kFd^Q1zH%?dO^hBWEEB=Qqo|h?U$oe1VtBxXZ&D1O_3XLjvz;{iEYx=MS`vWM zBflfueJD;5au2ulN+LTHkB4p9x~)(p{!yDjg(;JIc*Du^nTpaeDUrN1Rw_I~&J#~!ZTG4=T+rpE?T(0Sy4#0M4JNsJ z$A>q>I3_=emeZ)jj5M~I8|`FJl%QwjF|fHb5qyPhG9{JXFhiqeVG&9ofrUyn{>a>$ zM6TQJ$L7<+ctgtw)aB0;r1A`5z=&sunq@(OO=k}PiS#K+Z4|evWJR-)VwND^pp3+Q zjxiA#)!4(i#??c*-?bIVHp{hkh?{0NYv=HvlIeJU*K=qbRSER!VjlLE=6={HH#f$5 zZ3uxw0?T7UbX&>yr3QSLpw=MIJ+Y(v3&vMG1}CqMr9P|pke*fok%w!MP2A(Ji6>d~ zslsX0-+ootjHP3FtW4vRQfNkvm{*Uo=#eF*bXuQg^JM!z9bV*&Y^S}D5$awn5|VKg zTIiFWXVR)i9s*2mC5=6Pv25y66CS?@@)dc=SESBKCf>ky@` ze531a{SDPGAt z-!1-)i`$LRF56!JCONzYCR=o($re`7z07{u;azuZrdac8fR$Zw2cb>qWcI7nCOTHz z-SK?nWG0#CHj)N(t5*IlTAG*M-t>Qt4(5&vbF79dfZ{w_+TB%}!u~*j_tpEr)6T3l zTE%fVY(x@0;i*$)Q9Er}Z9%!h^Cwii;DE{sb%maAM@qkV8OLN)0cg1H07dC`6gb&Sny~KH{rV6!~&uF1#DQl6j@qW)7Vpyq1s6 z&AE5a!{fmfLH?Ar51vXT#UG73YjHw))7y*l&$;k~69MelZFqH2Obe?#?YN%z^-G!lH=e41eV^QL~l5Q|~d-8|N z^R12QsSm9by0el#O%-NNGQqaH*`Fa#IGZINKr*?!%Zk2R$r^M7T}C|Tm}1HUHF0mX z@C}0XTO*WTYN7(Zg5h~77lG5VeG}@zuCJSH zD>=90l=v)oK9x{j`UWdC6kj&SDIO)=6W-e?ceHu@oziwkKrNRr)}Iw4Ed^`CL#e_t zR75?*s3Fs}=(A)l`^XcJF}q#!Lc|*$EOtgYAN1#WZCjZPAnnKB@x3qZ$*50pEbfM*R%8EZ^4^y# zt$=p%!M)RvkJ-j~kLiG9KO_Fe zej%#}_C>89V#{{PDa%>X^-s&$X#(u7V9tm#@?<86N7i&T-Z;^+Kufc$*uFGF$5P%n zG@r=e{Nz)Y#%*C7bf##Ja3WRSyLT2m#68#dwq{nW44vv~!f_nzM=ZjZzJzH3 z>n0M`tOdN0#wF9pMlcN0_66B8Po#Y&#Z&%ilqQicvger9^zP92nI*lf>+P+IlS9JV z$@LoB)sht!=(BCMRPJSSzFjW=7JRaQMRWKy!Mr_%I3u|*c2!9ViF!T2iDQ)3f_at|B7AGwH zEPFM1?GiCTOhf*lq&vyWBSt%U(mwh$ajLSbH`Vc3DA;SI++L~>74%Rsn~KL)_Rn4} zu2e>&NY#(pemKbNAN3cm0|sI}C`mmTGp+|hv9(~AuUii5-%PbM(N8M)RNOWAGzYtR zEhZa+%`!AXf`2A^h+kb$)^REpHC7Z}Q0{gmg%v<&f$K91ZV7WzloE{r34{N{Wk4gS zVcoeb)Xi%^tg(mFpNc$Rt0Qy~-+>qV0#@;Ta@=Lr%r3JN9D2)@8{S zTd1uT11OrC)Oh~jaOraBce&J4F?xnO30HOjPXDRBUN!aj>S!)~m&*BMCAGO^?PYyZ zwk8@v^u@+L6KZXdb$qENvzDwY9CKUn=jqjs&FH^)YHfJ^1ljTUaQ#Mr48ftDrW}-N zuIoX@*aMmE(T=a0j{+M}=t+nVai=ZLD%>~B$Z<||JW$WA4XOUC*D3a{7q$F z!OGoB8+3>j-@9?Yg`f^v#4BBXnF?8hvg>VUbSv=zLyr$*e`DxT+cj#sIjE-YcQ5eS z>c#|<9e|7$G>&|&$8Ea#VJ#dQi``vkq;cDfb!`1~vGjYB>lLdtBNw~Q^fBV|v`SkY zFMoUc`{Jey%3bro+%C`1DRt5)TmOWWwa%GW`kAQ=yu+wV+Rph=&)DM#*_O~r6PfPJ z#UO7>%GD#w!$_D)M;Q-)s|4ABkr&nIXWeCvXE869>Kck+M|1w0oXhF~Sj~_;g)BkXO_o!Or zksS4(Ob&>P{zc#+&EY?J4aA)OlZm4Sf!u!>P+-t#LALk5zW`exI5SdGYNhf4Xg3F& zs;b1Tw9r=c_b*_cfw=7NPrH2y6kvaUmCPw9QU1P;!%+Yz|GCI5yVO7w^5+gP_c%e- zgP>g*yf*47`1>E>O=dGfgw7roG4#X#pZ@v3@`Nj(qk9kinqJb|cQO6znSgEp9?v}5 zHNIR)l7VO=DUJF9dAaZO%0&+}B$fi~u zgTZhC`3UWV@Lx$!H-Co;gSma}cXuNEWe^gzBco3&qscWf_$N#Od=UKa zC^(nE{~hl5Km74_`0q{u__-w0zF!Pzt79;lU^9VU0t`mxEinABYVenwU|$PM3jY-W zwu%2Qe(*B|T0DeqesEA8dQgDu>}RZ*=)Oveg!$OTNykac-q^UYL9G?pnh3eaz^<)n zM@fh6@uls5Z53Jnu&}-6N8OLT)Z>if7k8zf^P%N!1Td`vH1<4ZsfV@=NsCDS{{5OC zI#uJq$!*>#=Gki%XmG&XR@qz$%soNq+xY+Sk89EgUg-PY@Ds0v+uvnsCZ{YfE3Y6b fE2h9NqM#&uRzct(we}wPH_Um(3kvyiSAG8nzqq=} literal 0 HcmV?d00001 diff --git a/doc/figures/bdalg-ullft.png b/doc/figures/bdalg-ullft.png new file mode 100644 index 0000000000000000000000000000000000000000..92c00c3d15a2af19f04d00f644b2e0fc173c0db9 GIT binary patch literal 51702 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 literal 0 HcmV?d00001 diff --git a/doc/iosys.rst b/doc/iosys.rst index 5e51e7f05..adc85658c 100644 --- a/doc/iosys.rst +++ b/doc/iosys.rst @@ -75,6 +75,7 @@ Block diagram algebra is implemented using the following functions: series parallel feedback + lft negate append @@ -101,6 +102,64 @@ the following command will also work:: Gyu = G1.feedback(G2) +A generalized form of feedback interconnection, the star +product or linear fractional transformation (LFT), is +available via the :func:`lft` function and illustrated in the following +diagram: + +.. image:: figures/bdalg-lft.png + :width: 240 + :align: center + +This function creates the system `G` by connecting the last `ny` +outputs of `G1` (signal `y1`) to the first `ny` inputs of `G2`, and +the first `nu` outputs of `G2` (signal `y2`) to the last `nu` inputs +of `G1`. The resulting inputs of `G` are `[w1;w2]`, and the outputs +`[z1;z2]`. Such an interconnection could be created for `G1` and `G2` +of any `InputOutputSystem` type using: + +.. code:: + + Gzw = ct.lft(G1, G2, nu, ny) + +For `StateSpace` systems, the :func:`lft` function is also available +through the :func:`StateSpace.lft` method, where `G1` must be a +`StateSpace` and `G2` something convertible to `StateSpace` as shown +below: + +.. code:: + + Gzw = G1.lft(G2, nu, ny) + +If `nu` and `ny` are omitted, they default to the largest values +allowed by the shapes of `G1` and `G2`. This can be useful for +generating lower and upper LFTs, an example of which is shown in the +diagram below: + +.. image:: figures/bdalg-ullft.png + :width: 480 + :align: center + +To construct this example we define a plant `P`, controller `K` and +uncertainty block `Delta`: + +.. code:: + + P = ss(..., inputs=["u_delta", "w", "u"], outputs=["y_delta", "z", "v"]) + K = ss(..., input="v", output="u") + Delta = ss(..., input="y_delta", output="u_delta") + +The lower LFT `PK` and upper LFT `PDelta` can then be made using: + +.. code:: + + PK = ct.lft(P, K) + PDelta = ct.lft(Delta, P) + +It is important to enter the systems in this order as `ct.lft(P, Delta)` would +result in a lower LFT of `P` and `Delta` with `u` and `v` being connected to +the `Delta` block as opposed to `u_delta` and `y_delta`. + All block diagram algebra functions allow the name of the system and labels for signals to be specified using the usual `name`, `inputs`, and `outputs` keywords, as described in the :class:`InputOutputSystem` From 3c9752fb2de234ba83e8587d36ced44585c19868 Mon Sep 17 00:00:00 2001 From: tom Date: Sun, 13 Sep 2026 13:43:30 +0200 Subject: [PATCH 10/20] make lft be able to use scalar, array and tf inputs --- control/bdalg.py | 45 +++++++++++++------ control/tests/bdalg_test.py | 89 +++++++++++++++++++++++++++++++++---- doc/iosys.rst | 2 +- 3 files changed, 113 insertions(+), 23 deletions(-) diff --git a/control/bdalg.py b/control/bdalg.py index 4f8670cb7..712e65d95 100644 --- a/control/bdalg.py +++ b/control/bdalg.py @@ -327,8 +327,9 @@ def lft(sys1, sys2, nu=-1, ny=-1, **kwargs): Parameters ---------- - sys1, sys2 : `InputOutputSystem` - I/O systems to perform linear fractional transformation on. + sys1, sys2 : scalar, array, or `InputOutputSystem` + I/O systems to perform linear fractional transformation on. + `FrequencyResponseData` systems are not supported. ny : int, optional Dimension of (plant) measurement output that is connected to `sys2`. Must not exceed the number of outputs of `sys1` or @@ -367,7 +368,8 @@ def lft(sys1, sys2, nu=-1, ny=-1, **kwargs): inputs of `sys1` or the number of outputs of `sys2`. TypeError If `sys1` or `sys2` is not an I/O system, or cannot be - converted to one. + converted to one, or if either is a `FrequencyResponseData` + system. See Also -------- @@ -388,11 +390,30 @@ def lft(sys1, sys2, nu=-1, ny=-1, **kwargs): Decision and Control, Brighton, England, 1991, pp. 1227-1232. """ - if not isinstance(sys1, InputOutputSystem): - raise TypeError("sys1 must be an I/O system") - elif not isinstance(sys2, InputOutputSystem): - raise TypeError("sys2 must be an I/O system") + # Check for correct input types + if not isinstance(sys1, (int, float, complex, np.number, np.ndarray, + InputOutputSystem)): + raise TypeError("sys1 must be an I/O system, scalar, or array") + elif not isinstance(sys2, (int, float, complex, np.number, np.ndarray, + InputOutputSystem)): + raise TypeError("sys2 must be an I/O system, scalar, or array") + if isinstance(sys1, frd.FrequencyResponseData) or \ + isinstance(sys2, frd.FrequencyResponseData): + raise TypeError("FrequencyResponseData systems are not supported") + + # Convert systems to statespace if possible + convertible_types = ( + int, float, complex, np.number, np.ndarray, tf.TransferFunction, + ) + if isinstance(sys1, convertible_types): + sys1 = ss._convert_to_statespace(sys1) + if isinstance(sys2, convertible_types): + sys2 = ss._convert_to_statespace(sys2) + + if isinstance(sys1, ss.StateSpace) and isinstance(sys2, ss.StateSpace): + return sys1.lft(sys2, nu, ny, **kwargs) + # Check that nu, ny are within bounds if ny > sys1.noutputs or ny > sys2.ninputs: raise ValueError( @@ -409,10 +430,6 @@ def lft(sys1, sys2, nu=-1, ny=-1, **kwargs): if nu == -1: nu = min(sys2.noutputs, sys1.ninputs) - if isinstance(sys1, ss.StateSpace) and isinstance(sys2, ss.StateSpace): - sys1_ss = ss._convert_to_statespace(sys1) - return sys1_ss.lft(sys2, nu, ny, **kwargs) - n1i, n1o = sys1.ninputs, sys1.noutputs n2i, n2o = sys2.ninputs, sys2.noutputs @@ -429,12 +446,12 @@ def lft(sys1, sys2, nu=-1, ny=-1, **kwargs): [(1, i) for i in range(nu, n2o)] if not 'inputs' in kwargs: - inputs = sys1.input_labels[:sys1.ninputs-nu] + sys2.input_labels[ny:] + inputs = sys1.input_labels[:n1i-nu] + sys2.input_labels[ny:] kwargs['inputs'] = inputs if not 'outputs' in kwargs: - outputs = sys1.output_labels[:sys1.noutputs-ny] + sys2.output_labels[nu:] - kwargs['outputs'] = outputs + outputs = sys1.output_labels[:n1o-ny] + sys2.output_labels[nu:] + kwargs['outputs'] = outputs return interconnect( [sys1, sys2], connections=connections, diff --git a/control/tests/bdalg_test.py b/control/tests/bdalg_test.py index f2917a86e..a90140560 100644 --- a/control/tests/bdalg_test.py +++ b/control/tests/bdalg_test.py @@ -383,7 +383,7 @@ def test_lft_invalid_nu_ny(self, nu, ny, errmatch): with pytest.raises(ValueError, match=errmatch): lft(P, K, nu, ny) - def test_lft_labels(self): + def test_lft_label_propagation(self): """Test that lft() propagates signal labels and allows overrides.""" P = ctrl.rss( states=2, outputs=['y1_p', 'y2_p', 'y3_p'], @@ -411,15 +411,88 @@ def test_lft_labels(self): assert pk.output_labels == ['y1', 'y2', 'y3', 'y4'] assert pk.state_labels == ['x1', 'x2', 'x3', 'x4'] - def test_lft_bad_types(self): - """Non-system, non-convertible arguments should raise TypeError - rather than being silently routed into the general/interconnect - path.""" - P = ctrl.rss(states=2, outputs=2, inputs=2) + def test_lft_args(self): + P = ctrl.rss(states=2, outputs=2, inputs=2, strictly_proper=True) + + # If first argument is not LTI or convertable, generate an exception + args = ('hello world', P) with pytest.raises(TypeError): - lft('hello world', P) + lft(*args) + + # If second argument is not LTI or convertable, generate an exception + args = (P, 'hello world') with pytest.raises(TypeError): - lft(P, 'hello world') + lft(*args) + + # If first or second argument is FRD, generate an exception + h = TransferFunction([1], [1, 2, 2]) + omega = np.logspace(-1, 2, 10) + frd = ctrl.FRD(h, omega) + with pytest.raises(TypeError): + lft(1, frd) + with pytest.raises(TypeError): + lft(frd, 1) + + def test_lft_scalar_inputs(self): + """lft() should accept a scalar for either argument, converting + it to a static-gain StateSpace system, like feedback() does.""" + x1, x2 = 2.5, -3. + K = ctrl.rss(states=2, outputs=2, inputs=2, strictly_proper=True) + P = ctrl.rss(states=2, outputs=2, inputs=2, strictly_proper=True) + + ans = lft(x1, K) + ref = StateSpace([], [], [], [x1]).lft(K) + np.testing.assert_array_almost_equal(ans.A, ref.A) + np.testing.assert_array_almost_equal(ans.B, ref.B) + np.testing.assert_array_almost_equal(ans.C, ref.C) + np.testing.assert_array_almost_equal(ans.D, ref.D) + + ans = lft(P, x2) + ref = P.lft(StateSpace([], [], [], [x2])) + np.testing.assert_array_almost_equal(ans.A, ref.A) + np.testing.assert_array_almost_equal(ans.B, ref.B) + np.testing.assert_array_almost_equal(ans.C, ref.C) + np.testing.assert_array_almost_equal(ans.D, ref.D) + + def test_lft_array_inputs(self): + """lft() should accept an array for either argument, converting + it to a static-gain StateSpace system.""" + D1 = np.array([[1., 2.], [3., 4.]]) + D2 = np.array([[0.5, 0.], [0., 0.5]]) + K = ctrl.rss(states=2, outputs=2, inputs=2, strictly_proper=True) + P = ctrl.rss(states=2, outputs=2, inputs=2, strictly_proper=True) + + ans = lft(D1, K) + ref = StateSpace([], [], [], D1).lft(K) + np.testing.assert_array_almost_equal(ans.A, ref.A) + np.testing.assert_array_almost_equal(ans.B, ref.B) + np.testing.assert_array_almost_equal(ans.C, ref.C) + np.testing.assert_array_almost_equal(ans.D, ref.D) + + ans = lft(P, D2) + ref = P.lft(StateSpace([], [], [], D2)) + np.testing.assert_array_almost_equal(ans.A, ref.A) + np.testing.assert_array_almost_equal(ans.B, ref.B) + np.testing.assert_array_almost_equal(ans.C, ref.C) + np.testing.assert_array_almost_equal(ans.D, ref.D) + + @pytest.mark.parametrize('nu, ny', [(-1, -1), (1, 1)]) + def test_lft_tf_ss_mixed_inputs(self, nu, ny): + """lft() should accept a mix of TransferFunction and StateSpace + arguments, using the StateSpace.lft() fast path for both.""" + P_ss = ctrl.rss(states=2, outputs=2, inputs=2, strictly_proper=True) + K_ss = ctrl.rss(states=2, outputs=2, inputs=2, strictly_proper=True) + P_tf = ctrl.tf(P_ss) + K_tf = ctrl.tf(K_ss) + ref = P_ss.lft(K_ss, nu, ny) + + ans = lft(P_ss, K_tf, nu, ny) + for s in [0, 1, 1j]: + np.testing.assert_allclose(ans(s), ref(s), atol=1e-6) + + ans = lft(P_tf, K_ss, nu, ny) + for s in [0, 1, 1j]: + np.testing.assert_allclose(ans(s), ref(s), atol=1e-6) @pytest.mark.parametrize( diff --git a/doc/iosys.rst b/doc/iosys.rst index adc85658c..d1c86ffd3 100644 --- a/doc/iosys.rst +++ b/doc/iosys.rst @@ -116,7 +116,7 @@ outputs of `G1` (signal `y1`) to the first `ny` inputs of `G2`, and the first `nu` outputs of `G2` (signal `y2`) to the last `nu` inputs of `G1`. The resulting inputs of `G` are `[w1;w2]`, and the outputs `[z1;z2]`. Such an interconnection could be created for `G1` and `G2` -of any `InputOutputSystem` type using: +of any input/output system except `FrequencyResponseData` using: .. code:: From 351ba998b00d95f9599955d435d41b23bba1deae Mon Sep 17 00:00:00 2001 From: tom Date: Sun, 13 Sep 2026 17:50:37 +0200 Subject: [PATCH 11/20] add lft to see also of other bdalg functions --- control/bdalg.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/control/bdalg.py b/control/bdalg.py index 712e65d95..a19e227ae 100644 --- a/control/bdalg.py +++ b/control/bdalg.py @@ -66,7 +66,7 @@ def series(*sys, **kwargs): See Also -------- - append, feedback, interconnect, negate, parallel + append, feedback, interconnect, lft, negate, parallel Notes ----- @@ -139,7 +139,7 @@ def parallel(*sys, **kwargs): See Also -------- - append, feedback, interconnect, negate, series + append, feedback, interconnect, lft, negate, series Notes ----- @@ -201,7 +201,7 @@ def negate(sys, **kwargs): See Also -------- - append, feedback, interconnect, parallel, series + append, feedback, interconnect, lft, parallel, series Notes ----- @@ -266,7 +266,7 @@ def feedback(sys1, sys2=1, sign=-1, **kwargs): See Also -------- - append, interconnect, negate, parallel, series + append, interconnect, lft, negate, parallel, series Notes ----- @@ -494,7 +494,7 @@ def append(*sys, **kwargs): See Also -------- - interconnect, feedback, negate, parallel, series + interconnect, feedback, lft, negate, parallel, series Examples -------- @@ -556,7 +556,7 @@ def connect(sys, Q, inputv, outputv): See Also -------- - append, feedback, interconnect, negate, parallel, series + append, feedback, interconnect, lft, negate, parallel, series Notes ----- From b72c3e0ef9d61e7c166e206f9ff5a0e214afd8a0 Mon Sep 17 00:00:00 2001 From: tom Date: Sun, 13 Sep 2026 17:53:08 +0200 Subject: [PATCH 12/20] reorder checks to be more logical in lft function --- control/bdalg.py | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/control/bdalg.py b/control/bdalg.py index a19e227ae..6a9c07dd2 100644 --- a/control/bdalg.py +++ b/control/bdalg.py @@ -411,9 +411,12 @@ def lft(sys1, sys2, nu=-1, ny=-1, **kwargs): if isinstance(sys2, convertible_types): sys2 = ss._convert_to_statespace(sys2) - if isinstance(sys1, ss.StateSpace) and isinstance(sys2, ss.StateSpace): - return sys1.lft(sys2, nu, ny, **kwargs) - + # Maximal values for nu, ny + if ny == -1: + ny = min(sys2.ninputs, sys1.noutputs) + if nu == -1: + nu = min(sys2.noutputs, sys1.ninputs) + # Check that nu, ny are within bounds if ny > sys1.noutputs or ny > sys2.ninputs: raise ValueError( @@ -424,12 +427,11 @@ def lft(sys1, sys2, nu=-1, ny=-1, **kwargs): "nu can't exceed the number of inputs of sys1 or " "outputs of sys2") - # maximal values for nu, ny - if ny == -1: - ny = min(sys2.ninputs, sys1.noutputs) - if nu == -1: - nu = min(sys2.noutputs, sys1.ninputs) - + # If sys1 and sys2 are StateSpace, use ss.lft function + if isinstance(sys1, ss.StateSpace) and isinstance(sys2, ss.StateSpace): + return sys1.lft(sys2, nu, ny, **kwargs) + + # If sys1 and sys2 are not StateSpace, use interconnect n1i, n1o = sys1.ninputs, sys1.noutputs n2i, n2o = sys2.ninputs, sys2.noutputs From 3262a35ad6bd7579ea31741ca078a34850508a2b Mon Sep 17 00:00:00 2001 From: tom Date: Sun, 13 Sep 2026 17:54:13 +0200 Subject: [PATCH 13/20] compute maximal values before dimension check in ss.lft --- control/statesp.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/control/statesp.py b/control/statesp.py index f755a5ec4..531a01397 100644 --- a/control/statesp.py +++ b/control/statesp.py @@ -1119,6 +1119,13 @@ def lft(self, other, nu=-1, ny=-1, **kwargs): """ other = _convert_to_statespace(other) + + # maximal values for nu, ny + if ny == -1: + ny = min(other.ninputs, self.noutputs) + if nu == -1: + nu = min(other.noutputs, self.ninputs) + # dimension check if ny > self.noutputs or ny > other.ninputs: raise ValueError( @@ -1129,12 +1136,6 @@ def lft(self, other, nu=-1, ny=-1, **kwargs): "nu can't exceed the number of inputs of self or " "outputs of other") - # maximal values for nu, ny - if ny == -1: - ny = min(other.ninputs, self.noutputs) - if nu == -1: - nu = min(other.noutputs, self.ninputs) - dt = common_timebase(self.dt, other.dt) # submatrices From 2f6587194608954f5104a8b11f9213e57676b661 Mon Sep 17 00:00:00 2001 From: tom Date: Sun, 13 Sep 2026 18:52:40 +0200 Subject: [PATCH 14/20] reorder bdalg lft tests --- control/tests/bdalg_test.py | 86 ++++++++++++++----------------------- 1 file changed, 32 insertions(+), 54 deletions(-) diff --git a/control/tests/bdalg_test.py b/control/tests/bdalg_test.py index a90140560..670693d83 100644 --- a/control/tests/bdalg_test.py +++ b/control/tests/bdalg_test.py @@ -346,7 +346,7 @@ class TestLft: @pytest.mark.parametrize('nu, ny', [(-1, -1), (2, 1), (1, 2)]) def test_lft_matches_statespace_method(self, nu, ny): - """lft() should exactly reproduce StateSpace.lft() for SS inputs.""" + """Test that lft() reproduces StateSpace.lft() for SS inputs.""" P = ctrl.rss(states=3, outputs=3, inputs=3, strictly_proper=True) K = ctrl.rss(states=3, outputs=3, inputs=3, strictly_proper=True) @@ -358,21 +358,6 @@ def test_lft_matches_statespace_method(self, nu, ny): np.testing.assert_array_almost_equal(ans.C, ref.C) np.testing.assert_array_almost_equal(ans.D, ref.D) - @pytest.mark.parametrize('nu, ny', [(-1, -1), (1, 1)]) - def test_lft_tf_inputs(self, nu, ny): - """lft() should accept TransferFunction inputs, like - StateSpace.lft() does via conversion.""" - P_ss = ctrl.rss(states=2, outputs=2, inputs=2, strictly_proper=True) - K_ss = ctrl.rss(states=2, outputs=2, inputs=2, strictly_proper=True) - P_tf = ctrl.tf(P_ss) - K_tf = ctrl.tf(K_ss) - - ref = P_ss.lft(K_ss, nu, ny) - ans = lft(P_tf, K_tf, nu, ny) - - for s in [0, 1, 1j]: - np.testing.assert_allclose(ans(s), ref(s), atol=1e-6) - @pytest.mark.parametrize('nu, ny, errmatch', [(3, -1, "nu can't exceed"), (-1, 3, "ny can't exceed")]) @@ -411,31 +396,22 @@ def test_lft_label_propagation(self): assert pk.output_labels == ['y1', 'y2', 'y3', 'y4'] assert pk.state_labels == ['x1', 'x2', 'x3', 'x4'] - def test_lft_args(self): - P = ctrl.rss(states=2, outputs=2, inputs=2, strictly_proper=True) - - # If first argument is not LTI or convertable, generate an exception - args = ('hello world', P) - with pytest.raises(TypeError): - lft(*args) + @pytest.mark.parametrize('nu, ny', [(-1, -1), (1, 1)]) + def test_lft_tf_inputs(self, nu, ny): + """Test that lft() accepts TransferFunction inputs.""" + P_ss = ctrl.rss(states=2, outputs=2, inputs=2, strictly_proper=True) + K_ss = ctrl.rss(states=2, outputs=2, inputs=2, strictly_proper=True) + P_tf = ctrl.tf(P_ss) + K_tf = ctrl.tf(K_ss) - # If second argument is not LTI or convertable, generate an exception - args = (P, 'hello world') - with pytest.raises(TypeError): - lft(*args) + ref = P_ss.lft(K_ss, nu, ny) + ans = lft(P_tf, K_tf, nu, ny) - # If first or second argument is FRD, generate an exception - h = TransferFunction([1], [1, 2, 2]) - omega = np.logspace(-1, 2, 10) - frd = ctrl.FRD(h, omega) - with pytest.raises(TypeError): - lft(1, frd) - with pytest.raises(TypeError): - lft(frd, 1) + for s in [0, 1, 1j]: + np.testing.assert_allclose(ans(s), ref(s), atol=1e-6) def test_lft_scalar_inputs(self): - """lft() should accept a scalar for either argument, converting - it to a static-gain StateSpace system, like feedback() does.""" + """Test that lft() accepts a scalar for either argument.""" x1, x2 = 2.5, -3. K = ctrl.rss(states=2, outputs=2, inputs=2, strictly_proper=True) P = ctrl.rss(states=2, outputs=2, inputs=2, strictly_proper=True) @@ -455,8 +431,7 @@ def test_lft_scalar_inputs(self): np.testing.assert_array_almost_equal(ans.D, ref.D) def test_lft_array_inputs(self): - """lft() should accept an array for either argument, converting - it to a static-gain StateSpace system.""" + """Test that lft() accepts an array for either argument.""" D1 = np.array([[1., 2.], [3., 4.]]) D2 = np.array([[0.5, 0.], [0., 0.5]]) K = ctrl.rss(states=2, outputs=2, inputs=2, strictly_proper=True) @@ -476,24 +451,27 @@ def test_lft_array_inputs(self): np.testing.assert_array_almost_equal(ans.C, ref.C) np.testing.assert_array_almost_equal(ans.D, ref.D) - @pytest.mark.parametrize('nu, ny', [(-1, -1), (1, 1)]) - def test_lft_tf_ss_mixed_inputs(self, nu, ny): - """lft() should accept a mix of TransferFunction and StateSpace - arguments, using the StateSpace.lft() fast path for both.""" - P_ss = ctrl.rss(states=2, outputs=2, inputs=2, strictly_proper=True) - K_ss = ctrl.rss(states=2, outputs=2, inputs=2, strictly_proper=True) - P_tf = ctrl.tf(P_ss) - K_tf = ctrl.tf(K_ss) - ref = P_ss.lft(K_ss, nu, ny) + def test_lft_args(self): + P = ctrl.rss(states=2, outputs=2, inputs=2, strictly_proper=True) - ans = lft(P_ss, K_tf, nu, ny) - for s in [0, 1, 1j]: - np.testing.assert_allclose(ans(s), ref(s), atol=1e-6) + # If first argument is not LTI or convertable, generate an exception + args = ('hello world', P) + with pytest.raises(TypeError): + lft(*args) - ans = lft(P_tf, K_ss, nu, ny) - for s in [0, 1, 1j]: - np.testing.assert_allclose(ans(s), ref(s), atol=1e-6) + # If second argument is not LTI or convertable, generate an exception + args = (P, 'hello world') + with pytest.raises(TypeError): + lft(*args) + # If first or second argument is FRD, generate an exception + h = TransferFunction([1], [1, 2, 2]) + omega = np.logspace(-1, 2, 10) + frd = ctrl.FRD(h, omega) + with pytest.raises(TypeError): + lft(1, frd) + with pytest.raises(TypeError): + lft(frd, 1) @pytest.mark.parametrize( "op, nsys, ninputs, noutputs, nstates", [ From 4cc8e244d32488e901008eddc4bad72b835e36ee Mon Sep 17 00:00:00 2001 From: tom Date: Sun, 13 Sep 2026 19:03:12 +0200 Subject: [PATCH 15/20] add examples to lft functions --- control/bdalg.py | 14 ++++++++++++++ control/statesp.py | 14 ++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/control/bdalg.py b/control/bdalg.py index 6a9c07dd2..7a58dabc0 100644 --- a/control/bdalg.py +++ b/control/bdalg.py @@ -389,6 +389,20 @@ def lft(sys1, sys2, nu=-1, ny=-1, **kwargs): LMIs, and mu," Proceedings of the 30th IEEE Conference on Decision and Control, Brighton, England, 1991, pp. 1227-1232. + Examples + -------- + >>> G1 = ct.rss(3, inputs=3, outputs=3) + >>> G2 = ct.rss(3, inputs=3, outputs=3) + >>> G = ct.lft(G1, G2, nu=2, ny=1) + >>> G.ninputs, G.noutputs, G.nstates + (3, 3, 6) + + >>> G1 = ct.rss(3, inputs=4, outputs=4) + >>> G2 = ct.rss(2, inputs=2, outputs=2) + >>> G = ct.lft(G1, G2) + >>> G.ninputs, G.noutputs, G.nstates + (2, 2, 5) + """ # Check for correct input types if not isinstance(sys1, (int, float, complex, np.number, np.ndarray, diff --git a/control/statesp.py b/control/statesp.py index 531a01397..aba14dea6 100644 --- a/control/statesp.py +++ b/control/statesp.py @@ -1116,6 +1116,20 @@ def lft(self, other, nu=-1, ny=-1, **kwargs): LMIs, and mu," Proceedings of the 30th IEEE Conference on Decision and Control, Brighton, England, 1991, pp. 1227-1232. + Examples + -------- + >>> G1 = ct.rss(3, inputs=3, outputs=3) + >>> G2 = ct.rss(3, inputs=3, outputs=3) + >>> G = G1.lft(G2, nu=2, ny=1) + >>> G.ninputs, G.noutputs, G.nstates + (3, 3, 6) + + >>> G1 = ct.rss(3, inputs=4, outputs=4) + >>> G2 = ct.rss(2, inputs=2, outputs=2) + >>> G = G1.lft(G2) + >>> G.ninputs, G.noutputs, G.nstates + (2, 2, 5) + """ other = _convert_to_statespace(other) From 842a10c4873228238afbe68a25242947b4172f25 Mon Sep 17 00:00:00 2001 From: tom Date: Sun, 13 Sep 2026 19:30:20 +0200 Subject: [PATCH 16/20] fallback to default names if duplicate input or output names occur after performing lft --- control/bdalg.py | 12 ++++++++++-- control/statesp.py | 19 +++++++++++++++---- control/tests/bdalg_test.py | 8 ++++++++ control/tests/iosys_test.py | 18 ++++++++++++++++++ control/tests/statesp_test.py | 8 ++++++++ 5 files changed, 59 insertions(+), 6 deletions(-) diff --git a/control/bdalg.py b/control/bdalg.py index 7a58dabc0..d71d826a9 100644 --- a/control/bdalg.py +++ b/control/bdalg.py @@ -462,11 +462,19 @@ def lft(sys1, sys2, nu=-1, ny=-1, **kwargs): [(1, i) for i in range(nu, n2o)] if not 'inputs' in kwargs: - inputs = sys1.input_labels[:n1i-nu] + sys2.input_labels[ny:] + inputs = sys1.input_labels[:n1i-nu] + sys2.input_labels[ny:] + # If sys1 and sys2 have clashing input labels, fallback to + # default names + if len(set(inputs)) != len(inputs): + inputs = len(inputs) kwargs['inputs'] = inputs - + if not 'outputs' in kwargs: outputs = sys1.output_labels[:n1o-ny] + sys2.output_labels[nu:] + # If sys1 and sys2 have clashing output labels, fallback to + # default names + if len(set(outputs)) != len(outputs): + outputs = len(outputs) kwargs['outputs'] = outputs return interconnect( diff --git a/control/statesp.py b/control/statesp.py index aba14dea6..5b97d7097 100644 --- a/control/statesp.py +++ b/control/statesp.py @@ -1218,10 +1218,21 @@ def lft(self, other, nu=-1, ny=-1, **kwargs): [Dbar21 @ H11, Dbar22 + Dbar21 @ H12] ]) - inputs = self.input_labels[:self.ninputs-nu] + other.input_labels[ny:] - outputs = self.output_labels[:self.noutputs-ny] + other.output_labels[nu:] - sys = StateSpace(Ares, Bres, Cres, Dres, dt, inputs=inputs, outputs=outputs) - return StateSpace(sys, **kwargs) + inputs = self.input_labels[:self.ninputs-nu] + \ + other.input_labels[ny:] + outputs = self.output_labels[:self.noutputs-ny] + \ + other.output_labels[nu:] + + # If self and other have clashing input and output names, fallback + # to default names + if len(set(inputs)) != len(inputs): + inputs = len(inputs) + if len(set(outputs)) != len(outputs): + outputs = len(outputs) + + sys = StateSpace( + Ares, Bres, Cres, Dres, dt, inputs=inputs, outputs=outputs) + return StateSpace(sys, **kwargs) def minreal(self, tol=0.0): """Remove unobservable and uncontrollable states. diff --git a/control/tests/bdalg_test.py b/control/tests/bdalg_test.py index 670693d83..88c90c52c 100644 --- a/control/tests/bdalg_test.py +++ b/control/tests/bdalg_test.py @@ -396,6 +396,14 @@ def test_lft_label_propagation(self): assert pk.output_labels == ['y1', 'y2', 'y3', 'y4'] assert pk.state_labels == ['x1', 'x2', 'x3', 'x4'] + # check that labels go back to default if duplicate labels occur + P = ctrl.rss(states=3, inputs=4, outputs=4, strictly_proper=True) + K = ctrl.rss(states=2, inputs=3, outputs=3, strictly_proper=True) + + pk = lft(P, K, nu=2, ny=1) + assert pk.input_labels == ['u[0]', 'u[1]', 'u[2]', 'u[3]'] + assert pk.output_labels == ['y[0]', 'y[1]', 'y[2]', 'y[3]'] + @pytest.mark.parametrize('nu, ny', [(-1, -1), (1, 1)]) def test_lft_tf_inputs(self, nu, ny): """Test that lft() accepts TransferFunction inputs.""" diff --git a/control/tests/iosys_test.py b/control/tests/iosys_test.py index cb42d7588..f4a7107ec 100644 --- a/control/tests/iosys_test.py +++ b/control/tests/iosys_test.py @@ -664,6 +664,24 @@ def test_lft(self, tsys): assert sys_lft.output_labels == ['y1', 'y2', 'y3'] assert sys_lft.name == 'new_sys' + # check that labels go back to default if duplicate labels occur + sys3_ss = ct.rss(states=3, inputs=4, outputs=4, + strictly_proper=True) + sys4_ss = ct.rss(states=2, inputs=3, outputs=3, + strictly_proper=True) + sys3_nl = ct.NonlinearIOSystem( + lambda t, x, u, params: sys3_ss.A @ x + sys3_ss.B @ u, + lambda t, x, u, params: sys3_ss.C @ x + sys3_ss.D @ u, + states=3, inputs=4, outputs=4, name='sys3') + sys4_nl = ct.NonlinearIOSystem( + lambda t, x, u, params: sys4_ss.A @ x + sys4_ss.B @ u, + lambda t, x, u, params: sys4_ss.C @ x + sys4_ss.D @ u, + states=2, inputs=3, outputs=3, name='sys4') + + sys_lft = ct.lft(sys3_nl, sys4_nl, nu=2, ny=1) + assert sys_lft.input_labels == ['u[0]', 'u[1]', 'u[2]', 'u[3]'] + assert sys_lft.output_labels == ['y[0]', 'y[1]', 'y[2]', 'y[3]'] + def test_bdalg_functions(self, tsys): """Test block diagram functions algebra on I/O systems""" # Set up parameters for simulation diff --git a/control/tests/statesp_test.py b/control/tests/statesp_test.py index 554ff5e28..251cc0337 100644 --- a/control/tests/statesp_test.py +++ b/control/tests/statesp_test.py @@ -1093,6 +1093,14 @@ def test_lft_labels(self): assert pk.output_labels == ['y1', 'y2', 'y3', 'y4'] assert pk.state_labels == ['x1', 'x2', 'x3', 'x4'] + # check that labels go back to default if duplicate labels occur + P = rss(states=3, inputs=4, outputs=4, strictly_proper=True) + K = rss(states=2, inputs=3, outputs=3, strictly_proper=True) + + pk = P.lft(K, nu=2, ny=1) + assert pk.input_labels == ['u[0]', 'u[1]', 'u[2]', 'u[3]'] + assert pk.output_labels == ['y[0]', 'y[1]', 'y[2]', 'y[3]'] + def test_repr(self, sys322): """Test string representation""" ref322 = """StateSpace( From 7522036efcd5407714f384e6bfab80c24a9a62d6 Mon Sep 17 00:00:00 2001 From: tom Date: Sun, 13 Sep 2026 20:28:49 +0200 Subject: [PATCH 17/20] make definition of ny and nu more general in lft docstrings --- control/bdalg.py | 18 ++++++++---------- control/statesp.py | 14 +++++++------- 2 files changed, 15 insertions(+), 17 deletions(-) diff --git a/control/bdalg.py b/control/bdalg.py index d71d826a9..6a36c52e7 100644 --- a/control/bdalg.py +++ b/control/bdalg.py @@ -331,17 +331,15 @@ def lft(sys1, sys2, nu=-1, ny=-1, **kwargs): I/O systems to perform linear fractional transformation on. `FrequencyResponseData` systems are not supported. ny : int, optional - Dimension of (plant) measurement output that is connected to - `sys2`. Must not exceed the number of outputs of `sys1` or - the number of inputs of `sys2`. If not specified, defaults - to the largest value allowed by the shapes of `sys1` and - `sys2`. + Dimension of the output of `sys1` that is connected to `sys2`. + Must not exceed the number of outputs of `sys1` or the number + of inputs of `sys2`. If not specified, defaults to the + largest value allowed by the shapes of `sys1` and `sys2`. nu : int, optional - Dimension of (plant) control input that is generated by - `sys2`. Must not exceed the number of inputs of `sys1` or - the number of outputs of `sys2`. If not specified, defaults - to the largest value allowed by the shapes of `sys1` and - `sys2`. + Dimension of the output of `sys2` that is connected to `sys1`. + Must not exceed the number of inputs of `sys1` or the number + of outputs of `sys2`. If not specified, defaults to the + largest value allowed by the shapes of `sys1` and `sys2`. Returns ------- diff --git a/control/statesp.py b/control/statesp.py index 5b97d7097..dadb9d8b2 100644 --- a/control/statesp.py +++ b/control/statesp.py @@ -1074,14 +1074,14 @@ def lft(self, other, nu=-1, ny=-1, **kwargs): other : `StateSpace` The lower LTI system. ny : int, optional - Dimension of (plant) measurement output that is connected - to `other`. Must not exceed the number of outputs of - `self` or the number of inputs of `other`. If not - specified, defaults to the largest value allowed by the - shapes of `self` and `other`. + Dimension of the output of `self` that is connected to + `other`. Must not exceed the number of outputs of `self` + or the number of inputs of `other`. If not specified, + defaults to the largest value allowed by the shapes of + `self` and `other`. nu : int, optional - Dimension of (plant) control input that is generated by - `other`. Must not exceed the number of inputs of `self` + Dimension of the output of `other` that is connected to + `self`. Must not exceed the number of inputs of `self` or the number of outputs of `other`. If not specified, defaults to the largest value allowed by the shapes of `self` and `other`. From 66d1f201ee913ac5146a9da322b0570480ec0131 Mon Sep 17 00:00:00 2001 From: tom Date: Sun, 13 Sep 2026 20:40:24 +0200 Subject: [PATCH 18/20] align FRD and non-LTI system input error test for lft function --- control/tests/bdalg_test.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/control/tests/bdalg_test.py b/control/tests/bdalg_test.py index 88c90c52c..c03b87dc4 100644 --- a/control/tests/bdalg_test.py +++ b/control/tests/bdalg_test.py @@ -462,18 +462,17 @@ def test_lft_array_inputs(self): def test_lft_args(self): P = ctrl.rss(states=2, outputs=2, inputs=2, strictly_proper=True) - # If first argument is not LTI or convertable, generate an exception + # If first or second argument is not LTI or convertable to it, + # generate an exception args = ('hello world', P) with pytest.raises(TypeError): lft(*args) - - # If second argument is not LTI or convertable, generate an exception args = (P, 'hello world') with pytest.raises(TypeError): lft(*args) # If first or second argument is FRD, generate an exception - h = TransferFunction([1], [1, 2, 2]) + h = TransferFunction([1], [1, 2, 3]) omega = np.logspace(-1, 2, 10) frd = ctrl.FRD(h, omega) with pytest.raises(TypeError): From d2264c6231b5c8ed51601f9e36175e293d85c5bf Mon Sep 17 00:00:00 2001 From: tom Date: Mon, 14 Sep 2026 22:05:28 +0200 Subject: [PATCH 19/20] remove unnecessary blank line --- control/statesp.py | 1 - 1 file changed, 1 deletion(-) diff --git a/control/statesp.py b/control/statesp.py index dadb9d8b2..6a7b8f550 100644 --- a/control/statesp.py +++ b/control/statesp.py @@ -1133,7 +1133,6 @@ def lft(self, other, nu=-1, ny=-1, **kwargs): """ other = _convert_to_statespace(other) - # maximal values for nu, ny if ny == -1: ny = min(other.ninputs, self.noutputs) From 5a59926cb350a0581970d67ca0902546a3b4f3af Mon Sep 17 00:00:00 2001 From: Tom Aantjes Date: Tue, 15 Sep 2026 08:58:47 +0200 Subject: [PATCH 20/20] mark lft test that requires slycot --- control/tests/bdalg_test.py | 1 + 1 file changed, 1 insertion(+) diff --git a/control/tests/bdalg_test.py b/control/tests/bdalg_test.py index c03b87dc4..0bc185891 100644 --- a/control/tests/bdalg_test.py +++ b/control/tests/bdalg_test.py @@ -404,6 +404,7 @@ def test_lft_label_propagation(self): assert pk.input_labels == ['u[0]', 'u[1]', 'u[2]', 'u[3]'] assert pk.output_labels == ['y[0]', 'y[1]', 'y[2]', 'y[3]'] + @pytest.mark.slycot @pytest.mark.parametrize('nu, ny', [(-1, -1), (1, 1)]) def test_lft_tf_inputs(self, nu, ny): """Test that lft() accepts TransferFunction inputs."""