diff --git a/control/bdalg.py b/control/bdalg.py index 0ed490084..6a36c52e7 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): @@ -65,7 +66,7 @@ def series(*sys, **kwargs): See Also -------- - append, feedback, interconnect, negate, parallel + append, feedback, interconnect, lft, negate, parallel Notes ----- @@ -138,7 +139,7 @@ def parallel(*sys, **kwargs): See Also -------- - append, feedback, interconnect, negate, series + append, feedback, interconnect, lft, negate, series Notes ----- @@ -200,7 +201,7 @@ def negate(sys, **kwargs): See Also -------- - append, feedback, interconnect, parallel, series + append, feedback, interconnect, lft, parallel, series Notes ----- @@ -265,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 ----- @@ -313,6 +314,172 @@ 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 of two I/O systems. + + Forms the Redheffer star product of `sys1` and `sys2` [1]_. + 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`, + this forms the upper LFT of `sys2` and `sys1`. + + Parameters + ---------- + 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 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 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 + ------- + 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, or if either is a `FrequencyResponseData` + system. + + 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. + + 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, + 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) + + # 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( + "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 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 + + 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[: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( + [sys1, sys2], connections=connections, + inplist=inplist, outlist=outlist, **kwargs) + + def append(*sys, **kwargs): """append(sys1, sys2[, ..., sysn]) @@ -349,7 +516,7 @@ def append(*sys, **kwargs): See Also -------- - interconnect, feedback, negate, parallel, series + interconnect, feedback, lft, negate, parallel, series Examples -------- @@ -411,7 +578,7 @@ def connect(sys, Q, inputv, outputv): See Also -------- - append, feedback, interconnect, negate, parallel, series + append, feedback, interconnect, lft, negate, parallel, series Notes ----- diff --git a/control/statesp.py b/control/statesp.py index 8091e29ed..6a7b8f550 100644 --- a/control/statesp.py +++ b/control/statesp.py @@ -1058,40 +1058,96 @@ 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, - 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 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. + 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`. 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. + + 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) + # 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 + 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") dt = common_timebase(self.dt, other.dt) @@ -1160,7 +1216,22 @@ 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:] + + # 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 63d3c42cf..0bc185891 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,146 @@ 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): + """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) + + 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, 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_label_propagation(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'] + + # 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.slycot + @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) + + 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) + + def test_lft_scalar_inputs(self): + """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) + + 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): + """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) + 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) + + def test_lft_args(self): + P = ctrl.rss(states=2, outputs=2, inputs=2, strictly_proper=True) + + # 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) + 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, 3]) + 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", [ (ctrl.series, 2, 1, 1, 4), diff --git a/control/tests/iosys_test.py b/control/tests/iosys_test.py index 5d741ae83..f4a7107ec 100644 --- a/control/tests/iosys_test.py +++ b/control/tests/iosys_test.py @@ -594,6 +594,94 @@ 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' + + # 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 @@ -640,6 +728,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 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, diff --git a/control/tests/statesp_test.py b/control/tests/statesp_test.py index 9b3c677fe..251cc0337 100644 --- a/control/tests/statesp_test.py +++ b/control/tests/statesp_test.py @@ -1055,6 +1055,52 @@ def test_lft(self): 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( + 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'] + + # 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( diff --git a/doc/figures/bdalg-lft.png b/doc/figures/bdalg-lft.png new file mode 100644 index 000000000..ac041f64b Binary files /dev/null and b/doc/figures/bdalg-lft.png differ diff --git a/doc/figures/bdalg-ullft.png b/doc/figures/bdalg-ullft.png new file mode 100644 index 000000000..92c00c3d1 Binary files /dev/null and b/doc/figures/bdalg-ullft.png differ 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 diff --git a/doc/iosys.rst b/doc/iosys.rst index 5e51e7f05..d1c86ffd3 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 input/output system except `FrequencyResponseData` 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`