tf2ss 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- tf2ss/__init__.py +13 -0
- tf2ss/test_tf2ss.py +180 -0
- tf2ss/tf2ss.py +401 -0
- tf2ss/tf2ss_numeric.py +299 -0
- tf2ss/tf2ss_report.md +23 -0
- tf2ss/timeresp.py +495 -0
- tf2ss-1.0.0.dist-info/METADATA +303 -0
- tf2ss-1.0.0.dist-info/RECORD +10 -0
- tf2ss-1.0.0.dist-info/WHEEL +4 -0
- tf2ss-1.0.0.dist-info/licenses/LICENSE +21 -0
tf2ss/__init__.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Convert your MISO/SIMO/MIMO systems from transfer function to state-space.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from .tf2ss import _get_lcm_norm_coeffs, _pad_numerators, tf2ss
|
|
6
|
+
from .timeresp import forced_response
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
"_get_lcm_norm_coeffs",
|
|
10
|
+
"_pad_numerators",
|
|
11
|
+
"forced_response",
|
|
12
|
+
"tf2ss",
|
|
13
|
+
]
|
tf2ss/test_tf2ss.py
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
"""Tests of tf2ss on various sample systems and consistency with SLYCOT."""
|
|
2
|
+
|
|
3
|
+
import control as ctrl
|
|
4
|
+
import numpy as np
|
|
5
|
+
import pytest
|
|
6
|
+
from control.exception import slycot_check
|
|
7
|
+
from scipy import signal
|
|
8
|
+
|
|
9
|
+
from tf2ss import _get_lcm_norm_coeffs, _pad_numerators, tf2ss
|
|
10
|
+
|
|
11
|
+
systems = [
|
|
12
|
+
([[[0.5]]], [[[32]]]),
|
|
13
|
+
([[[1]]], [[[1, 2]]]), # H(s) = 1 / (s+2)
|
|
14
|
+
([[[1, 1]]], [[[1, 3, 2]]]), # H(s) = (s+1) / (s^2 + 3s + 2)
|
|
15
|
+
(
|
|
16
|
+
[[[1], [0.5]], [[0], [1]]],
|
|
17
|
+
[[[1, 2], [1, 2]], [[0, 1], [1, 2]]],
|
|
18
|
+
), # MIMO case - different denominators
|
|
19
|
+
(
|
|
20
|
+
[
|
|
21
|
+
[[0.0, 0.0, 1.0], [1.0, 0.0, 0.0]],
|
|
22
|
+
[[3.0, -1.0, 1.0], [0.0, 1.0, 0.0]],
|
|
23
|
+
[[0.0, 0.0, 1.0], [0.0, 2.0, 0.0]],
|
|
24
|
+
],
|
|
25
|
+
[
|
|
26
|
+
[[1.0, 0.4, 3.0], [1.0, 1.0, 0.0]],
|
|
27
|
+
[[1.0, 0.4, 3.0], [1.0, 1.0, 0.0]],
|
|
28
|
+
[[1.0, 0.4, 3.0], [1.0, 1.0, 0.0]],
|
|
29
|
+
],
|
|
30
|
+
), # MIMO case - different denominators
|
|
31
|
+
(
|
|
32
|
+
[
|
|
33
|
+
[[1.0, 6.0, 12.0, 7.0], [0.0, 1.0, 4.0, 3.0]],
|
|
34
|
+
[[0.0, 0.0, 1.0, 1.0], [1.0, 8.0, 20.0, 15.0]],
|
|
35
|
+
],
|
|
36
|
+
[
|
|
37
|
+
[[1.0, 6.0, 11.0, 6.0], [1.0, 6.0, 11.0, 6.0]],
|
|
38
|
+
[[1.0, 6.0, 11.0, 6.0], [1.0, 6.0, 11.0, 6.0]],
|
|
39
|
+
],
|
|
40
|
+
),
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def compare_poles(poles_1, poles_2):
|
|
45
|
+
# Check if all poles are stable (real part < 0)
|
|
46
|
+
assert np.all(poles_1 <= 0), "Unstable poles in 1"
|
|
47
|
+
assert np.all(poles_2 <= 0), "Unstable poles in 2"
|
|
48
|
+
|
|
49
|
+
# Sort poles by their real parts (most dominant first)
|
|
50
|
+
poles_1 = sorted(poles_1, key=lambda x: -np.real(x))
|
|
51
|
+
poles_2 = sorted(poles_2, key=lambda x: -np.real(x))
|
|
52
|
+
|
|
53
|
+
# Find the number of dominant poles to compare
|
|
54
|
+
min_length = min(len(poles_1), len(poles_2))
|
|
55
|
+
|
|
56
|
+
# Log information about the different dimensions if they exist
|
|
57
|
+
if len(poles_1) != len(poles_2):
|
|
58
|
+
print(
|
|
59
|
+
f"Note: Different number of poles detected - A_1: {len(poles_1)}, A_2: {len(poles_2)}"
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
# Compare the dominant poles
|
|
63
|
+
for i in range(min_length):
|
|
64
|
+
assert np.isclose(poles_1[i], poles_2[i], rtol=1e-5, atol=1e-5), (
|
|
65
|
+
f"Dominant pole {i} differs: {poles_1[i]} vs {poles_2[i]}"
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def compare_systems(sys_1: signal.StateSpace, sys_2: signal.StateSpace):
|
|
70
|
+
# Additional check: compare system responses
|
|
71
|
+
# This ensures that even with different numbers of poles, the systems behave similarly
|
|
72
|
+
t = np.linspace(0, 10, 1000) # Time vector for simulation
|
|
73
|
+
|
|
74
|
+
# Generate step responses
|
|
75
|
+
# TODO: fix error - ValueError: System does not define that many inputs.
|
|
76
|
+
_, y_1 = signal.step(sys_1, T=t)
|
|
77
|
+
_, y_2 = signal.step(sys_2, T=t)
|
|
78
|
+
|
|
79
|
+
# Compare step responses
|
|
80
|
+
assert np.allclose(y_1, y_2, rtol=1e-4, atol=1e-4), (
|
|
81
|
+
"Step responses differ between original and minimal"
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
assert np.allclose(sys_1.A, sys_2.A, atol=1e-6), "Mismatch in A matrix"
|
|
85
|
+
assert np.allclose(sys_1.B, sys_2.B, atol=1e-6), "Mismatch in B matrix"
|
|
86
|
+
assert np.allclose(sys_1.C, sys_2.C, atol=1e-6), "Mismatch in C matrix"
|
|
87
|
+
assert np.allclose(sys_1.D, sys_2.D, atol=1e-6), "Mismatch in D matrix"
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@pytest.mark.parametrize("num, den", systems)
|
|
91
|
+
def test_vs_slycot(num, den):
|
|
92
|
+
if not slycot_check():
|
|
93
|
+
pytest.skip("Slycot not available, skipping test")
|
|
94
|
+
A, B, C, D = tf2ss(num, den, minreal=False)
|
|
95
|
+
|
|
96
|
+
# Convert to control.TransferFunction format
|
|
97
|
+
n_outputs = len(num)
|
|
98
|
+
n_inputs = len(num[0])
|
|
99
|
+
tf_mimo = [
|
|
100
|
+
[ctrl.TransferFunction(num[i][j], den[i][j]) for j in range(n_inputs)]
|
|
101
|
+
for i in range(n_outputs)
|
|
102
|
+
]
|
|
103
|
+
|
|
104
|
+
# Convert using control.tf2ss (uses slycot if available)
|
|
105
|
+
A_slycot, B_slycot, C_slycot, D_slycot = ctrl.ssdata(
|
|
106
|
+
ctrl.append(*[ctrl.append(*row) for row in tf_mimo])
|
|
107
|
+
)
|
|
108
|
+
poles = np.linalg.eigvals(A)
|
|
109
|
+
poles_slycot = np.linalg.eigvals(A_slycot)
|
|
110
|
+
import control
|
|
111
|
+
|
|
112
|
+
sys1 = control.StateSpace(A, B, C, D)
|
|
113
|
+
sys2 = control.StateSpace(A_slycot, B_slycot, C_slycot, D_slycot)
|
|
114
|
+
|
|
115
|
+
# Get transmission zeros for both systems
|
|
116
|
+
zeros1 = control.zeros(sys1)
|
|
117
|
+
zeros2 = control.zeros(sys2)
|
|
118
|
+
|
|
119
|
+
# Sort zeros for consistent comparison
|
|
120
|
+
zeros1 = np.sort_complex(zeros1)
|
|
121
|
+
zeros2 = np.sort_complex(zeros2)
|
|
122
|
+
print("zero found by us", zeros1)
|
|
123
|
+
print("zeros found by slycot", zeros2)
|
|
124
|
+
|
|
125
|
+
# Ensure same number of zeros
|
|
126
|
+
assert len(zeros1) == len(zeros2), (
|
|
127
|
+
f"Number of zeros differs: {len(zeros1)} vs {len(zeros2)}"
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
# Compare each zero
|
|
131
|
+
for i in range(len(zeros1)):
|
|
132
|
+
assert np.isclose(zeros1[i], zeros2[i], rtol=1e-5, atol=1e-5), (
|
|
133
|
+
f"Zero {i} differs: {zeros1[i]} vs {zeros2[i]}"
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
print("poles found by us", poles)
|
|
137
|
+
print("poles found by slycot", poles_slycot)
|
|
138
|
+
compare_poles(poles, poles_slycot)
|
|
139
|
+
|
|
140
|
+
# compare_systems(
|
|
141
|
+
# signal.StateSpace(A, B, C, D),
|
|
142
|
+
# signal.StateSpace(A_slycot, B_slycot, C_slycot, D_slycot),
|
|
143
|
+
# )
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
@pytest.mark.parametrize("num, den", systems)
|
|
147
|
+
def test_tf2ss_runability(num, den):
|
|
148
|
+
A, B, C, D = tf2ss(_pad_numerators(num), den)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
@pytest.mark.parametrize(
|
|
152
|
+
"den",
|
|
153
|
+
[
|
|
154
|
+
[
|
|
155
|
+
[
|
|
156
|
+
[1.0, 0.5], # (s + 0.5)
|
|
157
|
+
[1.0, 0.5, 0.25], # (s^2 + 0.5s + 0.25)
|
|
158
|
+
]
|
|
159
|
+
], # [1.0, 1.0, 0.75, 0.125]
|
|
160
|
+
[[[1, 7, 6], [1, -5, -6]]],
|
|
161
|
+
[[[3, -6, -9, 0], [7, 21, 14, 0, 0]]],
|
|
162
|
+
[
|
|
163
|
+
[[3, -6, -9, 0], [1, 0]],
|
|
164
|
+
[[7, 21, 14, 0, 0], [1, 0]],
|
|
165
|
+
],
|
|
166
|
+
[[[3, -6, -9, 0], [7, 21, 14, 0, 0]], [[1, 7, 6], [1, -5, -6]]],
|
|
167
|
+
],
|
|
168
|
+
)
|
|
169
|
+
def test_coeffs_retrieval(den):
|
|
170
|
+
den_our = _get_lcm_norm_coeffs(den, mode="local")
|
|
171
|
+
sys = ctrl.tf(
|
|
172
|
+
[[[1] * len(den) for den in row] for row in den],
|
|
173
|
+
den,
|
|
174
|
+
)
|
|
175
|
+
if isinstance(sys, ctrl.TransferFunction):
|
|
176
|
+
_, den_ctrl, _ = sys._common_den()
|
|
177
|
+
_, den_min, _ = sys.minreal()._common_den()
|
|
178
|
+
assert np.allclose(den_our, den_ctrl, atol=1e-5)
|
|
179
|
+
# TODO: create test evaluating minimum realization when implemented
|
|
180
|
+
# assert np.allclose(den_our, den_min, atol=1e-5)
|
tf2ss/tf2ss.py
ADDED
|
@@ -0,0 +1,401 @@
|
|
|
1
|
+
"""Helper functions for tf2ss conversion."""
|
|
2
|
+
|
|
3
|
+
from math import gcd
|
|
4
|
+
from typing import Any, Literal, overload
|
|
5
|
+
|
|
6
|
+
import numpy as np
|
|
7
|
+
import sympy as sp
|
|
8
|
+
from control import TransferFunction, tf
|
|
9
|
+
from numpy.typing import NDArray
|
|
10
|
+
from scipy.signal import tf2ss as tf2ss_siso
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _pad_numerators(
|
|
14
|
+
numerators: list[list[list[int | float]]],
|
|
15
|
+
) -> list[list[list[int | float]]]:
|
|
16
|
+
"""Pad the numerator matrix of polynomials with zeros to have all equal length, padding from the left.
|
|
17
|
+
|
|
18
|
+
Parameters:
|
|
19
|
+
numerators: List of lists of numerators for each (output, input) transfer function.
|
|
20
|
+
|
|
21
|
+
Returns:
|
|
22
|
+
Padded numerator list with all numerators having the same length.
|
|
23
|
+
|
|
24
|
+
Examples:
|
|
25
|
+
>>> _pad_numerators([[[1], [1, 2]], [[1, 2, 3], [1]]])
|
|
26
|
+
[[[0, 0, 1], [0, 1, 2]], [[1, 2, 3], [0, 0, 1]]]
|
|
27
|
+
"""
|
|
28
|
+
max_len = max(len(num) for row in numerators for num in row)
|
|
29
|
+
pad_with = 0 if isinstance(numerators[0][0][0], int) else 0.0
|
|
30
|
+
# type_ = type(numerators[0][0][0])
|
|
31
|
+
padded_numerators = [
|
|
32
|
+
[[pad_with] * (max_len - len(num)) + num for num in row]
|
|
33
|
+
for row in numerators
|
|
34
|
+
]
|
|
35
|
+
return padded_numerators
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def list_to_poly(coefs: list[Any], s: sp.Symbol = sp.Symbol("s")) -> sp.Poly:
|
|
39
|
+
"""Convert a list of coefficients (in descending order) into a sympy Poly.
|
|
40
|
+
|
|
41
|
+
Parameters:
|
|
42
|
+
coefs: List of coefficients (descending order).
|
|
43
|
+
For example, [1, 3, 2] represents 1*s**2 + 3*s + 2.
|
|
44
|
+
s: sympy symbol (default is s).
|
|
45
|
+
|
|
46
|
+
Returns:
|
|
47
|
+
A sympy Poly object.
|
|
48
|
+
|
|
49
|
+
Examples:
|
|
50
|
+
>>> p = list_to_poly([1, 3, 2])
|
|
51
|
+
>>> sp.expand(p.as_expr())
|
|
52
|
+
s**2 + 3*s + 2
|
|
53
|
+
>>> sp.expand(p.as_expr()) == sp.expand(sp.Symbol('s')**2 + 3*sp.Symbol('s') + 2)
|
|
54
|
+
True
|
|
55
|
+
"""
|
|
56
|
+
poly_expr = sum(
|
|
57
|
+
coef * s ** (len(coefs) - i - 1) for i, coef in enumerate(coefs)
|
|
58
|
+
)
|
|
59
|
+
return sp.Poly(poly_expr, s)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def compute_lcd_from_denominators(
|
|
63
|
+
denominators: list[list[list[float]]], s: sp.Symbol = sp.Symbol("s")
|
|
64
|
+
) -> sp.Poly:
|
|
65
|
+
"""Compute the least common denominator (LCD) of a MIMO system's denominators.
|
|
66
|
+
|
|
67
|
+
Parameters:
|
|
68
|
+
denominators: A list of lists of denominators. Each denominator is a list of coefficients
|
|
69
|
+
in descending order.
|
|
70
|
+
s: sympy symbol (default is s).
|
|
71
|
+
|
|
72
|
+
Returns:
|
|
73
|
+
A sympy Poly representing the LCD.
|
|
74
|
+
|
|
75
|
+
Examples:
|
|
76
|
+
>>> lcd = compute_lcd_from_denominators([[[1, 2]], [[1, 2]]])
|
|
77
|
+
>>> sp.expand(lcd.as_expr())
|
|
78
|
+
s + 2
|
|
79
|
+
"""
|
|
80
|
+
# Start with the first denominator in the list
|
|
81
|
+
first_poly = list_to_poly(denominators[0][0], s)
|
|
82
|
+
lcd = first_poly
|
|
83
|
+
for row in denominators:
|
|
84
|
+
for den in row:
|
|
85
|
+
poly = list_to_poly(den, s)
|
|
86
|
+
lcd = sp.lcm(lcd, poly)
|
|
87
|
+
return lcd
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def compute_adjusted_num(
|
|
91
|
+
numerator: list[Any],
|
|
92
|
+
lcd: sp.Poly,
|
|
93
|
+
denominator: list[Any],
|
|
94
|
+
s: sp.Symbol = sp.Symbol("s"),
|
|
95
|
+
) -> list[Any]:
|
|
96
|
+
"""Compute the adjusted numerator polynomial coefficients given the numerator and denominator
|
|
97
|
+
of a transfer function and the common LCD.
|
|
98
|
+
|
|
99
|
+
This function multiplies the original numerator by the LCD and divides by the original
|
|
100
|
+
denominator. The resulting quotient (assumed to be exact) gives the adjusted numerator.
|
|
101
|
+
|
|
102
|
+
Parameters:
|
|
103
|
+
numerator: List of numerator coefficients (in descending order).
|
|
104
|
+
lcd: A sympy Poly representing the least common denominator.
|
|
105
|
+
denominator: List of denominator coefficients (in descending order).
|
|
106
|
+
s: sympy symbol (default is s).
|
|
107
|
+
|
|
108
|
+
Returns:
|
|
109
|
+
A numpy array of adjusted numerator coefficients (in descending order).
|
|
110
|
+
|
|
111
|
+
Examples:
|
|
112
|
+
>>> compute_adjusted_num([1, 1], list_to_poly([1, 3, 2]), [1, 3, 2])
|
|
113
|
+
[1, 1]
|
|
114
|
+
"""
|
|
115
|
+
num_poly = list_to_poly(numerator, s)
|
|
116
|
+
den_poly = list_to_poly(denominator, s)
|
|
117
|
+
# Multiply numerator polynomial by LCD
|
|
118
|
+
new_expr = sp.expand(num_poly.as_expr() * lcd.as_expr())
|
|
119
|
+
new_poly = sp.Poly(new_expr, s)
|
|
120
|
+
quotient, remainder = sp.div(new_poly, den_poly)
|
|
121
|
+
if remainder.as_expr() != 0:
|
|
122
|
+
raise ValueError("Adjusted numerator division has non-zero remainder")
|
|
123
|
+
return quotient.all_coeffs()
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def transpose(matrix: list[list[Any]]) -> list[list[Any]]:
|
|
127
|
+
"""Transpose a list of lists (matrix).
|
|
128
|
+
|
|
129
|
+
Parameters:
|
|
130
|
+
matrix: List of lists to be transposed.
|
|
131
|
+
|
|
132
|
+
Returns:
|
|
133
|
+
Transposed list of lists.
|
|
134
|
+
|
|
135
|
+
Examples:
|
|
136
|
+
>>> transpose([[1, 2, 3], [4, 5, 6]])
|
|
137
|
+
[[1, 4], [2, 5], [3, 6]]
|
|
138
|
+
"""
|
|
139
|
+
return [list(row) for row in zip(*matrix)]
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def state_space_from_poly(poly: sp.Poly) -> tuple[NDArray, NDArray]:
|
|
143
|
+
"""Compute the state-space representation (A, B) from the denominator polynomial using tf2ss.
|
|
144
|
+
|
|
145
|
+
Parameters:
|
|
146
|
+
poly: A sympy Poly representing the denominator.
|
|
147
|
+
|
|
148
|
+
Returns:
|
|
149
|
+
A, B: Matrices from the tf2ss representation of the system with transfer function 1/poly.
|
|
150
|
+
|
|
151
|
+
Examples:
|
|
152
|
+
>>> A, B = state_space_from_poly(list_to_poly([1, 2]))
|
|
153
|
+
>>> A.shape
|
|
154
|
+
(1, 1)
|
|
155
|
+
>>> B.shape
|
|
156
|
+
(1, 1)
|
|
157
|
+
"""
|
|
158
|
+
lcd_coeffs = np.array(poly.all_coeffs(), dtype=np.float64)
|
|
159
|
+
A, B, _, _ = tf2ss_siso([1], lcd_coeffs)
|
|
160
|
+
return A, B
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
@overload
|
|
164
|
+
def _get_lcm_norm_coeffs(
|
|
165
|
+
denominators: list[list[list[float]]],
|
|
166
|
+
mode: Literal["global"],
|
|
167
|
+
) -> list[float]: ...
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
@overload
|
|
171
|
+
def _get_lcm_norm_coeffs(
|
|
172
|
+
denominators: list[list[list[float]]],
|
|
173
|
+
mode: Literal["local"],
|
|
174
|
+
) -> list[list[float]]: ...
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _get_lcm_norm_coeffs(
|
|
178
|
+
denominators: list[list[list[float]]],
|
|
179
|
+
mode: Literal["global", "local"] = "global",
|
|
180
|
+
) -> list[float] | list[list[float]]:
|
|
181
|
+
"""Compute the least common multiple (LCM) of a list of floating-point polynomials.
|
|
182
|
+
|
|
183
|
+
Parameters:
|
|
184
|
+
denominators: A list of lists of lists of floating-point coefficients representing the denominators.
|
|
185
|
+
mode: A string indicating the mode of LCM computation. Can be "global" or "local".
|
|
186
|
+
"global" computes a single LCM for all denominators.
|
|
187
|
+
"local" computes LCMs for each input of denominators.
|
|
188
|
+
|
|
189
|
+
Returns:
|
|
190
|
+
If mode is "global", returns a list of floating-point coefficients representing the LCM.
|
|
191
|
+
If mode is "local", returns a list of lists of floating-point coefficients representing the LCMs for each column.
|
|
192
|
+
|
|
193
|
+
Examples:
|
|
194
|
+
>>> denominators = [[[1.0, 2.0], [1.0, 1.0]], [[1.0, 2.0], [1.0, 3.0]]]
|
|
195
|
+
>>> _get_lcm_norm_coeffs(denominators, mode="global")
|
|
196
|
+
[1.0, 6.0, 11.0, 6.0]
|
|
197
|
+
|
|
198
|
+
>>> denominators = [[[1.0, 2.0], [1.0, 1.0]], [[1.0, 2.0], [1.0, 3.0]]]
|
|
199
|
+
>>> _get_lcm_norm_coeffs(denominators, mode="local")
|
|
200
|
+
[[1.0, 2.0, 0.0], [1.0, 4.0, 3.0]]
|
|
201
|
+
"""
|
|
202
|
+
if mode == "local":
|
|
203
|
+
# TransferFunction.common_den() right-pads the denominators with zeros
|
|
204
|
+
normalized_coeffs_list = [
|
|
205
|
+
_get_lcm_norm_coeffs([col], "global")
|
|
206
|
+
for col in transpose(denominators)
|
|
207
|
+
]
|
|
208
|
+
max_len = max(len(coeffs) for coeffs in normalized_coeffs_list)
|
|
209
|
+
return [
|
|
210
|
+
coeffs + [0.0] * (max_len - len(coeffs))
|
|
211
|
+
for coeffs in normalized_coeffs_list
|
|
212
|
+
]
|
|
213
|
+
else:
|
|
214
|
+
lcm_poly = compute_lcd_from_denominators(denominators)
|
|
215
|
+
# Extract coefficients as floats
|
|
216
|
+
lcd_coeffs = [float(c) for c in lcm_poly.all_coeffs()]
|
|
217
|
+
|
|
218
|
+
# Normalize by the greatest common divisor of integer coefficients
|
|
219
|
+
coeff_gcd = gcd(*(int(c) for c in lcd_coeffs if c != 0))
|
|
220
|
+
normalized_coeffs = [c / coeff_gcd for c in lcd_coeffs]
|
|
221
|
+
return normalized_coeffs
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def rjust(list_: list[int | float], width) -> list[int | float]:
|
|
225
|
+
"""Examples:
|
|
226
|
+
>>> rjust([1, 2, 3], 4)
|
|
227
|
+
[1, 2, 3, 0]
|
|
228
|
+
>>> rjust([1, 2, 3, 4, 5], 4)
|
|
229
|
+
[1, 2, 3, 4]
|
|
230
|
+
"""
|
|
231
|
+
pad_with = 0 if isinstance(list_[0], int) else 0.0
|
|
232
|
+
|
|
233
|
+
return list(list_[:width]) + [pad_with] * max(width - len(list_), 0)
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def controllable_canonical_form(
|
|
237
|
+
denominator: list[float] | sp.Poly,
|
|
238
|
+
) -> tuple[NDArray, NDArray]:
|
|
239
|
+
"""Compute the controllable canonical form (A, B) matrices for a given common denominator polynomial.
|
|
240
|
+
|
|
241
|
+
Parameters:
|
|
242
|
+
denominator (list): Coefficients of the denominator polynomial [a_n, ..., a_1, a_0],
|
|
243
|
+
where the highest-degree term is first.
|
|
244
|
+
|
|
245
|
+
Returns:
|
|
246
|
+
tuple: (A, B) state-space representation in controllable canonical form.
|
|
247
|
+
|
|
248
|
+
Examples:
|
|
249
|
+
>>> controllable_canonical_form([1, 2, 3])
|
|
250
|
+
(array([[ 0., 1.],
|
|
251
|
+
[-2., -3.]]), array([[0.],
|
|
252
|
+
[1.]]))
|
|
253
|
+
|
|
254
|
+
"""
|
|
255
|
+
if isinstance(denominator, np.ndarray):
|
|
256
|
+
denominator = list(denominator)
|
|
257
|
+
elif isinstance(denominator, sp.Poly):
|
|
258
|
+
denominator = list(
|
|
259
|
+
np.array(denominator.all_coeffs(), dtype=np.float64)
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
n = len(denominator) - 1 # System order
|
|
263
|
+
if n < 1:
|
|
264
|
+
return np.array([0.0]), np.array([0.0])
|
|
265
|
+
# Construct the A matrix in controllable canonical form
|
|
266
|
+
A = np.zeros((n, n))
|
|
267
|
+
A[:-1, 1:] = np.eye(n - 1) # Upper diagonal ones
|
|
268
|
+
A[-1, :] = -np.array(
|
|
269
|
+
denominator[1:]
|
|
270
|
+
) # Last row is negative denominator coefficients
|
|
271
|
+
|
|
272
|
+
# Construct the B matrix (last column is 1)
|
|
273
|
+
B = np.zeros((n, 1))
|
|
274
|
+
B[-1, 0] = 1
|
|
275
|
+
|
|
276
|
+
return A, B
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
# Underscores required for positional only arguments (https://github.com/python/mypy/issues/6187)
|
|
280
|
+
@overload
|
|
281
|
+
def tf2ss(
|
|
282
|
+
__sys: TransferFunction,
|
|
283
|
+
*,
|
|
284
|
+
minreal: bool = True,
|
|
285
|
+
) -> tuple[NDArray, NDArray, NDArray, NDArray]: ...
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
@overload
|
|
289
|
+
def tf2ss(
|
|
290
|
+
__numerators: list[list[list[int | float]]],
|
|
291
|
+
__denominators: list[list[list[int | float]]],
|
|
292
|
+
*,
|
|
293
|
+
minreal: bool = True,
|
|
294
|
+
) -> tuple[NDArray, NDArray, NDArray, NDArray]: ...
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def tf2ss(
|
|
298
|
+
*args: TransferFunction | list[list[list[int | float]]],
|
|
299
|
+
minreal: bool = True,
|
|
300
|
+
) -> tuple[NDArray, NDArray, NDArray, NDArray]:
|
|
301
|
+
"""Convert a MIMO transfer function to a minimal state-space realization.
|
|
302
|
+
|
|
303
|
+
Parameters:
|
|
304
|
+
*args: Either a single TransferFunction object or two lists (numerators and denominators).
|
|
305
|
+
minreal: Whether to perform minimal realization (default is True).
|
|
306
|
+
|
|
307
|
+
Returns:
|
|
308
|
+
A, B, C, D: Minimal state-space matrices of the MIMO system.
|
|
309
|
+
|
|
310
|
+
Examples:
|
|
311
|
+
>>> sys = TransferFunction([1], [1, 2])
|
|
312
|
+
>>> A, B, C, D = tf2ss(sys)
|
|
313
|
+
>>> A.shape[0] > 0
|
|
314
|
+
True
|
|
315
|
+
|
|
316
|
+
>>> numerators = [[[1]], [[1]]]
|
|
317
|
+
>>> denominators = [[[1, 2]], [[1, 2]]]
|
|
318
|
+
>>> A, B, C, D = tf2ss(numerators, denominators)
|
|
319
|
+
>>> A.shape[0] > 0
|
|
320
|
+
True
|
|
321
|
+
"""
|
|
322
|
+
if len(args) == 1:
|
|
323
|
+
sys = args[0]
|
|
324
|
+
if not isinstance(sys, TransferFunction):
|
|
325
|
+
raise ValueError(
|
|
326
|
+
"Single argument must be a TransferFunction object."
|
|
327
|
+
)
|
|
328
|
+
elif len(args) == 2:
|
|
329
|
+
numerators, denominators = args
|
|
330
|
+
if not (
|
|
331
|
+
isinstance(numerators, list) and isinstance(denominators, list)
|
|
332
|
+
):
|
|
333
|
+
raise ValueError(
|
|
334
|
+
"Two arguments must be lists of numerators and denominators."
|
|
335
|
+
)
|
|
336
|
+
sys = tf(numerators, denominators)
|
|
337
|
+
else:
|
|
338
|
+
raise ValueError(
|
|
339
|
+
"Invalid number of arguments. Provide either a TransferFunction or numerators and denominators."
|
|
340
|
+
)
|
|
341
|
+
|
|
342
|
+
if sys is None:
|
|
343
|
+
raise ValueError("Invalid transfer function")
|
|
344
|
+
|
|
345
|
+
if minreal:
|
|
346
|
+
sys = sys.minreal(tol=1e-8)
|
|
347
|
+
|
|
348
|
+
# TODO: Apparently, the common_den() method changes the number of outputs of the system (tested for case where noutputs < ninputs)
|
|
349
|
+
numerators_, denominators_, _ = sys._common_den()
|
|
350
|
+
|
|
351
|
+
denominators_ = np.expand_dims(denominators_, axis=0)
|
|
352
|
+
denominators_ = np.tile(denominators_, (numerators_.shape[0], 1, 1))
|
|
353
|
+
|
|
354
|
+
# Remove the last row of numerators if all elements are zero
|
|
355
|
+
# Seems to be a bug in control.common_den() that adds an extra row
|
|
356
|
+
def trim_zeros_along_axis(arr, axis, trim="b"):
|
|
357
|
+
"""Trip zeros along the given axis, preserving at last one array."""
|
|
358
|
+
slices = [slice(None)] * arr.ndim
|
|
359
|
+
for idx in range(arr.shape[axis]):
|
|
360
|
+
slices[axis] = idx if trim == "f" else -idx - 1
|
|
361
|
+
if not np.all(arr[tuple(slices)] == 0):
|
|
362
|
+
break
|
|
363
|
+
start = idx if trim == "f" else 0
|
|
364
|
+
end = arr.shape[axis] - idx if trim == "b" else arr.shape[axis]
|
|
365
|
+
slices[axis] = slice(start, end)
|
|
366
|
+
return arr[tuple(slices)]
|
|
367
|
+
|
|
368
|
+
numerators_ = trim_zeros_along_axis(numerators_, axis=1, trim="b")
|
|
369
|
+
numerators_ = np.vectorize(lambda x: sp.Rational(str(x)))(numerators_)
|
|
370
|
+
denominators_ = np.vectorize(lambda x: sp.Rational(str(x)))(denominators_)
|
|
371
|
+
n_outputs = len(numerators_)
|
|
372
|
+
n_inputs = len(numerators_[0])
|
|
373
|
+
s = sp.Symbol("s")
|
|
374
|
+
# Step 1: Compute the LCD of all denominators.
|
|
375
|
+
lcd = compute_lcd_from_denominators(denominators_, s)
|
|
376
|
+
# Step 2: Get state-space representation (A, B) from the LCD.
|
|
377
|
+
# A, B_scalar = controllable_canonical_form(lcd)
|
|
378
|
+
A, B_scalar = state_space_from_poly(lcd)
|
|
379
|
+
n_states = A.shape[0]
|
|
380
|
+
C = np.zeros((n_outputs, n_states))
|
|
381
|
+
D = np.zeros((n_outputs, n_inputs))
|
|
382
|
+
# Step 3: For each transfer function, compute the adjusted numerator and fill C and D.
|
|
383
|
+
for i_out in range(n_outputs):
|
|
384
|
+
for j_in in range(n_inputs):
|
|
385
|
+
den = (
|
|
386
|
+
denominators_[i_out][j_in]
|
|
387
|
+
if denominators_.ndim == 3
|
|
388
|
+
else denominators_[j_in]
|
|
389
|
+
)
|
|
390
|
+
adjusted_num_coeffs = compute_adjusted_num(
|
|
391
|
+
numerators_[i_out][j_in], lcd, den, s
|
|
392
|
+
)
|
|
393
|
+
# In the controllable canonical (companion) form, the output matrix uses the reversed order.
|
|
394
|
+
C[i_out, :] = rjust(adjusted_num_coeffs[::-1], n_states)
|
|
395
|
+
# If the degree is less than n_states, assign the constant term to D.
|
|
396
|
+
if len(adjusted_num_coeffs) < n_states:
|
|
397
|
+
D[i_out, j_in] = adjusted_num_coeffs[-1]
|
|
398
|
+
else:
|
|
399
|
+
D[i_out, j_in] = 0
|
|
400
|
+
B = np.tile(B_scalar, (1, n_inputs))
|
|
401
|
+
return A, B, C, D
|