DirectSD-Python 0.1.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.
- directsd/__init__.py +141 -0
- directsd/analysis/__init__.py +4 -0
- directsd/analysis/charpol.py +89 -0
- directsd/analysis/errors.py +185 -0
- directsd/analysis/norms.py +630 -0
- directsd/design/__init__.py +9 -0
- directsd/design/convex.py +620 -0
- directsd/design/lifting.py +403 -0
- directsd/design/polynomial.py +5925 -0
- directsd/examples/__init__.py +0 -0
- directsd/examples/_common.py +36 -0
- directsd/examples/demos.py +1161 -0
- directsd/examples/examples.py +296 -0
- directsd/examples/help_examples.py +1043 -0
- directsd/glopt/__init__.py +4 -0
- directsd/glopt/advanced.py +1658 -0
- directsd/glopt/optimize.py +420 -0
- directsd/linalg/__init__.py +3 -0
- directsd/linalg/linsys.py +66 -0
- directsd/linalg/matrices.py +156 -0
- directsd/linalg/minreal.py +425 -0
- directsd/linalg/riccati.py +197 -0
- directsd/polynomial/__init__.py +11 -0
- directsd/polynomial/diophantine.py +426 -0
- directsd/polynomial/operations.py +247 -0
- directsd/polynomial/poln.py +742 -0
- directsd/polynomial/spectral.py +368 -0
- directsd/polynomial/transforms.py +124 -0
- directsd/polynomial/utils.py +449 -0
- directsd/sspace/__init__.py +4 -0
- directsd/sspace/design.py +1083 -0
- directsd/sspace/plant.py +198 -0
- directsd/tf/__init__.py +9 -0
- directsd/tf/interconnect.py +105 -0
- directsd/zpk/__init__.py +1 -0
- directsd/zpk/zpk.py +400 -0
- directsd_python-0.1.0.dist-info/METADATA +450 -0
- directsd_python-0.1.0.dist-info/RECORD +41 -0
- directsd_python-0.1.0.dist-info/WHEEL +5 -0
- directsd_python-0.1.0.dist-info/licenses/LICENSE +36 -0
- directsd_python-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,1083 @@
|
|
|
1
|
+
"""
|
|
2
|
+
State-space controller design for sampled-data systems.
|
|
3
|
+
|
|
4
|
+
Ports of: h2reg, hinfreg, sdh2reg, sdhinfreg, sdfast, separss
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import numpy as np
|
|
8
|
+
import warnings
|
|
9
|
+
|
|
10
|
+
from directsd.linalg.minreal import Minreal
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _bilin_d2c(sys_d, n_meas=0, n_ctrl=0):
|
|
14
|
+
"""Bilinear (Tustin) DT→CT state-space transform.
|
|
15
|
+
|
|
16
|
+
Uses the sqrt(T)-scaled Tustin convention consistent with scipy's
|
|
17
|
+
cont2discrete(method='bilinear'). Zeroes the D22 block (bottom-right
|
|
18
|
+
n_meas × n_ctrl submatrix of D) so that hinfreg (which assumes D22=0)
|
|
19
|
+
receives a conforming plant. For small T this is an O(T) approximation.
|
|
20
|
+
"""
|
|
21
|
+
import scipy.signal as sig
|
|
22
|
+
Ad, Bd, Cd, Dd = sys_d.A, sys_d.B, sys_d.C, sys_d.D
|
|
23
|
+
T = float(sys_d.dt)
|
|
24
|
+
n = Ad.shape[0]
|
|
25
|
+
In = np.eye(n)
|
|
26
|
+
P = np.linalg.solve(Ad + In, In) # P = (Ad + I)^{-1}
|
|
27
|
+
sqrtT = np.sqrt(T)
|
|
28
|
+
Ac = (2.0 / T) * (P @ (Ad - In))
|
|
29
|
+
Bc = (2.0 / sqrtT) * (P @ Bd)
|
|
30
|
+
Cc = (2.0 / sqrtT) * (Cd @ P)
|
|
31
|
+
Dc = Dd - Cd @ P @ Bd
|
|
32
|
+
if n_meas > 0 and n_ctrl > 0:
|
|
33
|
+
Dc[-n_meas:, -n_ctrl:] = 0.0 # zero D22_c (O(T) correction)
|
|
34
|
+
return sig.StateSpace(Ac, Bc, Cc, Dc) # CT system (dt=None)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _bilin_c2d(sys_c, T):
|
|
38
|
+
"""Bilinear (Tustin) CT→DT state-space transform (scipy standard)."""
|
|
39
|
+
import scipy.signal as sig
|
|
40
|
+
result = sig.cont2discrete(
|
|
41
|
+
(sys_c.A, sys_c.B, sys_c.C, sys_c.D), T, method='bilinear'
|
|
42
|
+
)
|
|
43
|
+
return sig.StateSpace(*result[:4], dt=T)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _hinf_freq(A, B, C, D, is_discrete, N_freq=512):
|
|
47
|
+
"""Frequency-sweep estimate of ||C(zI-A)^-1 B + D||_inf (sup singular value).
|
|
48
|
+
|
|
49
|
+
Shared by hinfreg's initial gamma guess and its discrete-time closed-loop
|
|
50
|
+
gamma re-verification (see hinfreg's DT->CT->DT bilinear wrapping).
|
|
51
|
+
"""
|
|
52
|
+
n = A.shape[0]
|
|
53
|
+
freqs = np.linspace(1e-3, np.pi, N_freq) if is_discrete else np.linspace(1e-3, 1e3, N_freq)
|
|
54
|
+
pts = np.exp(1j * freqs) if is_discrete else 1j * freqs
|
|
55
|
+
sigma_max = 0.0
|
|
56
|
+
I_n = np.eye(n)
|
|
57
|
+
for pt in pts:
|
|
58
|
+
Rmat = pt * I_n - A
|
|
59
|
+
H = C @ np.linalg.solve(Rmat, B) + D
|
|
60
|
+
sv = np.linalg.svd(H, compute_uv=False)
|
|
61
|
+
sigma_max = max(sigma_max, sv[0])
|
|
62
|
+
return sigma_max
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _ssbal(A, B, C):
|
|
66
|
+
"""Diagonal state-space scaling (approximates MATLAB ssbal).
|
|
67
|
+
|
|
68
|
+
Finds a diagonal T (powers of 2) such that T*A*T^{-1} has balanced
|
|
69
|
+
row/column norms, then applies the same T to B and C^{-1} to C.
|
|
70
|
+
Improves DARE conditioning for mixed-scale state-space systems.
|
|
71
|
+
"""
|
|
72
|
+
import scipy.linalg as la
|
|
73
|
+
n = A.shape[0]
|
|
74
|
+
if n == 0:
|
|
75
|
+
return A, B, C
|
|
76
|
+
try:
|
|
77
|
+
A_bal, T_mat = la.matrix_balance(A, permute=False, separate=False)
|
|
78
|
+
t = np.diag(T_mat).copy()
|
|
79
|
+
t = np.where(np.abs(t) < 1e-300, 1.0, t)
|
|
80
|
+
B_bal = B * t[:, np.newaxis] # T @ B (row scaling)
|
|
81
|
+
C_bal = C / t[np.newaxis, :] # C @ T^{-1} (column de-scaling)
|
|
82
|
+
return A_bal, B_bal, C_bal
|
|
83
|
+
except Exception:
|
|
84
|
+
return A, B, C
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _h2_freq(A, B, C, D, N=512):
|
|
88
|
+
"""H2-norm via frequency-domain integration on unit circle (DT).
|
|
89
|
+
|
|
90
|
+
Uses a half-point grid to avoid z=1 (where pole-zero cancellation may
|
|
91
|
+
cause near-singular solves). Works correctly when the TF has
|
|
92
|
+
numerically-cancelled poles on the unit circle (minreal-equivalent).
|
|
93
|
+
|
|
94
|
+
H2^2 = (1/N) * sum_k ||H(e^{j w_k})||_F^2
|
|
95
|
+
where w_k = 2pi * (k + 0.5) / N.
|
|
96
|
+
"""
|
|
97
|
+
n = A.shape[0]
|
|
98
|
+
w = 2.0 * np.pi * (np.arange(N) + 0.5) / N
|
|
99
|
+
z = np.exp(1j * w)
|
|
100
|
+
I_n = np.eye(n)
|
|
101
|
+
h2sq = 0.0
|
|
102
|
+
for zk in z:
|
|
103
|
+
Hk = C @ np.linalg.solve(zk * I_n - A, B) + D
|
|
104
|
+
h2sq += np.real(np.trace(Hk.conj().T @ Hk))
|
|
105
|
+
return float(np.sqrt(max(h2sq / N, 0.0)))
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def h2reg(sys_ss, n_meas=1, n_ctrl=1, tol=1e-4):
|
|
109
|
+
"""
|
|
110
|
+
H2-optimal controller for an LTI system (state-space approach).
|
|
111
|
+
|
|
112
|
+
Uses scipy's LQR/Kalman solution via ARE (Algebraic Riccati Equation).
|
|
113
|
+
|
|
114
|
+
Parameters
|
|
115
|
+
----------
|
|
116
|
+
sys_ss : scipy.signal.StateSpace or (A,B,C,D) tuple
|
|
117
|
+
Generalized plant in state-space form.
|
|
118
|
+
n_meas : int
|
|
119
|
+
Number of measurements (controller inputs).
|
|
120
|
+
n_ctrl : int
|
|
121
|
+
Number of control inputs (controller outputs).
|
|
122
|
+
tol : float
|
|
123
|
+
Tolerance.
|
|
124
|
+
|
|
125
|
+
Returns
|
|
126
|
+
-------
|
|
127
|
+
K : scipy.signal.StateSpace
|
|
128
|
+
H2-optimal controller.
|
|
129
|
+
h2norm : float
|
|
130
|
+
H2-norm of the closed-loop system.
|
|
131
|
+
"""
|
|
132
|
+
try:
|
|
133
|
+
import scipy.signal as sig
|
|
134
|
+
import scipy.linalg as la
|
|
135
|
+
except ImportError:
|
|
136
|
+
raise ImportError("scipy is required.")
|
|
137
|
+
|
|
138
|
+
if isinstance(sys_ss, sig.StateSpace):
|
|
139
|
+
A, B, C, D = sys_ss.A, sys_ss.B, sys_ss.C, sys_ss.D
|
|
140
|
+
dt = sys_ss.dt
|
|
141
|
+
elif isinstance(sys_ss, tuple):
|
|
142
|
+
A, B, C, D = sys_ss
|
|
143
|
+
dt = None # assume continuous
|
|
144
|
+
else:
|
|
145
|
+
raise TypeError(f"Unsupported system type {type(sys_ss)}")
|
|
146
|
+
|
|
147
|
+
# Normalize: dt=None or dt=0 means continuous-time
|
|
148
|
+
is_discrete = (dt is not None and dt != 0)
|
|
149
|
+
|
|
150
|
+
# Minimal realization (port of MATLAB regular.m's very first step:
|
|
151
|
+
# sys=minreal(ss(sys))). GeneralizedPlant's naive block construction can
|
|
152
|
+
# produce a non-minimal realization with uncontrollable/unobservable
|
|
153
|
+
# marginal (integrator) modes — those make the ARE solves below fail with
|
|
154
|
+
# "Failed to find a finite solution" regardless of any D12/D21
|
|
155
|
+
# regularization, since detectability/stabilizability genuinely fails on
|
|
156
|
+
# the redundant states. This must run on the *full* 4-block system before
|
|
157
|
+
# splitting into B1/B2/C1/C2, matching regular.m's order.
|
|
158
|
+
A, B, C, D = Minreal.ss(A, B, C, D)
|
|
159
|
+
|
|
160
|
+
n = A.shape[0]
|
|
161
|
+
nout, nin = C.shape[0], B.shape[1]
|
|
162
|
+
i1 = nin - n_ctrl
|
|
163
|
+
o1 = nout - n_meas
|
|
164
|
+
|
|
165
|
+
B1 = B[:, :i1]
|
|
166
|
+
B2 = B[:, i1:]
|
|
167
|
+
C1 = C[:o1, :]
|
|
168
|
+
C2 = C[o1:, :]
|
|
169
|
+
D11 = D[:o1, :i1]
|
|
170
|
+
D12 = D[:o1, i1:]
|
|
171
|
+
D21 = D[o1:, :i1]
|
|
172
|
+
D22 = D[o1:, i1:]
|
|
173
|
+
|
|
174
|
+
# State-space balancing (mirrors MATLAB h2reg ssbal step) — improves DARE conditioning
|
|
175
|
+
A, BC_bal, CC_bal = _ssbal(A, np.hstack([B1, B2]), np.vstack([C1, C2]))
|
|
176
|
+
B1 = BC_bal[:, :i1]; B2 = BC_bal[:, i1:]
|
|
177
|
+
C1 = CC_bal[:o1, :]; C2 = CC_bal[o1:, :]
|
|
178
|
+
|
|
179
|
+
eps_reg = 1e-6 # regularization for near-singular matrices (CT/legacy only)
|
|
180
|
+
|
|
181
|
+
if is_discrete:
|
|
182
|
+
# ---- Chen & Francis method (MATLAB h2reg.m 'ch', lines 123-201) ----
|
|
183
|
+
# The lifted sampled-data H2/L2 problem is generically SINGULAR
|
|
184
|
+
# (R = D12'D12 tiny, D21 = 0) and MARGINAL (integrator eigenvalue on
|
|
185
|
+
# the unit circle). dare1 (extended-pencil QZ, dsdlinalg/dare1.m)
|
|
186
|
+
# handles both; R is used AS-IS — a scipy DARE + eps-regularization
|
|
187
|
+
# approach either fails here (falling back to an identity-weight
|
|
188
|
+
# LQR, i.e. a different problem, → near-zero K) or biases R by ~10%
|
|
189
|
+
# (→ 55× suboptimal K). D11 enters through the F0/L0 feed-through
|
|
190
|
+
# pair, which a filter-form LQG assembly has no counterpart for.
|
|
191
|
+
from directsd.linalg.riccati import dare1 as _dare1
|
|
192
|
+
try:
|
|
193
|
+
Q_lqr = C1.T @ C1
|
|
194
|
+
R_lqr = D12.T @ D12
|
|
195
|
+
N_lqr = C1.T @ D12
|
|
196
|
+
X, _, _ = _dare1(A, B2, Q_lqr, R_lqr, N_lqr)
|
|
197
|
+
RBX = R_lqr + B2.T @ X @ B2
|
|
198
|
+
F = -la.solve(RBX, B2.T @ X @ A + D12.T @ C1)
|
|
199
|
+
F0 = -la.solve(RBX, B2.T @ X @ B1 + D12.T @ D11)
|
|
200
|
+
|
|
201
|
+
Q_kal = B1 @ B1.T
|
|
202
|
+
R_kal = D21 @ D21.T
|
|
203
|
+
N_kal = B1 @ D21.T
|
|
204
|
+
Y, _, _ = _dare1(A.T, C2.T, Q_kal, R_kal, N_kal)
|
|
205
|
+
SY = R_kal + C2 @ Y @ C2.T
|
|
206
|
+
# right division M/SY = solve(SY', M')'
|
|
207
|
+
L = -la.solve(SY.T, (A @ Y @ C2.T + B1 @ D21.T).T).T
|
|
208
|
+
L0 = la.solve(SY.T, (F @ Y @ C2.T + F0 @ D21.T).T).T
|
|
209
|
+
|
|
210
|
+
Ac = A + B2 @ F + L @ C2 - B2 @ L0 @ C2
|
|
211
|
+
Bc = L - B2 @ L0
|
|
212
|
+
Cc = L0 @ C2 - F
|
|
213
|
+
Dc = L0
|
|
214
|
+
except Exception as _cf_exc:
|
|
215
|
+
# Fallback path as a last resort: scipy DARE with
|
|
216
|
+
# eps-regularized weights and filter-form LQG assembly.
|
|
217
|
+
warnings.warn(f"h2reg: Chen-Francis/dare1 path failed "
|
|
218
|
+
f"({_cf_exc}); falling back to regularized LQG")
|
|
219
|
+
R_lqr = D12.T @ D12 + eps_reg * np.eye(n_ctrl)
|
|
220
|
+
N_lqr = C1.T @ D12
|
|
221
|
+
try:
|
|
222
|
+
X = la.solve_discrete_are(A, B2, C1.T @ C1, R_lqr, s=N_lqr)
|
|
223
|
+
F = -la.solve(R_lqr + B2.T @ X @ B2, B2.T @ X @ A + D12.T @ C1)
|
|
224
|
+
except (np.linalg.LinAlgError, ValueError):
|
|
225
|
+
try:
|
|
226
|
+
A_shift = A * 0.999
|
|
227
|
+
X = la.solve_discrete_are(A_shift, B2, np.eye(n), np.eye(n_ctrl))
|
|
228
|
+
F = -la.solve(np.eye(n_ctrl) + B2.T @ X @ B2, B2.T @ X @ A_shift)
|
|
229
|
+
except (np.linalg.LinAlgError, ValueError):
|
|
230
|
+
F = -np.linalg.lstsq(B2, A, rcond=None)[0]
|
|
231
|
+
R_kal = D21 @ D21.T + eps_reg * np.eye(n_meas)
|
|
232
|
+
N_kal = B1 @ D21.T
|
|
233
|
+
try:
|
|
234
|
+
Y = la.solve_discrete_are(A.T, C2.T, B1 @ B1.T, R_kal, s=N_kal)
|
|
235
|
+
S_k = R_kal + C2 @ Y @ C2.T
|
|
236
|
+
K_f = Y @ C2.T @ la.inv(S_k)
|
|
237
|
+
except (np.linalg.LinAlgError, ValueError):
|
|
238
|
+
Y = la.solve_discrete_are(A.T, C2.T, np.eye(n), np.eye(n_meas))
|
|
239
|
+
S_k = np.eye(n_meas) + C2 @ Y @ C2.T
|
|
240
|
+
K_f = Y @ C2.T @ la.inv(S_k)
|
|
241
|
+
Af = A + B2 @ F
|
|
242
|
+
IKC = np.eye(n) - K_f @ C2
|
|
243
|
+
Ac = Af @ IKC
|
|
244
|
+
Bc = Af @ K_f
|
|
245
|
+
Cc = F @ IKC
|
|
246
|
+
Dc = F @ K_f
|
|
247
|
+
else:
|
|
248
|
+
# ---- Continuous: standard LQG (Safonov & Chiang style) ----
|
|
249
|
+
Q_lqr = C1.T @ C1
|
|
250
|
+
R_lqr = D12.T @ D12 + eps_reg * np.eye(n_ctrl)
|
|
251
|
+
N_lqr = C1.T @ D12
|
|
252
|
+
try:
|
|
253
|
+
X = la.solve_continuous_are(A, B2, Q_lqr, R_lqr, e=None, s=N_lqr)
|
|
254
|
+
F = -la.solve(R_lqr, B2.T @ X + D12.T @ C1)
|
|
255
|
+
except (np.linalg.LinAlgError, ValueError):
|
|
256
|
+
try:
|
|
257
|
+
X = la.solve_continuous_are(A, B2, np.eye(n), np.eye(n_ctrl))
|
|
258
|
+
F = -la.solve(np.eye(n_ctrl), B2.T @ X)
|
|
259
|
+
except (np.linalg.LinAlgError, ValueError):
|
|
260
|
+
F = -np.linalg.lstsq(B2, A, rcond=None)[0]
|
|
261
|
+
|
|
262
|
+
Q_kal = B1 @ B1.T
|
|
263
|
+
R_kal = D21 @ D21.T + eps_reg * np.eye(n_meas)
|
|
264
|
+
N_kal = B1 @ D21.T
|
|
265
|
+
try:
|
|
266
|
+
Y = la.solve_continuous_are(A.T, C2.T, Q_kal, R_kal, e=None, s=N_kal)
|
|
267
|
+
L = -(Y @ C2.T + N_kal) @ la.inv(R_kal)
|
|
268
|
+
except (np.linalg.LinAlgError, ValueError):
|
|
269
|
+
Y = la.solve_continuous_are(A.T, C2.T, np.eye(n), np.eye(n_meas))
|
|
270
|
+
L = -Y @ C2.T @ la.inv(np.eye(n_meas))
|
|
271
|
+
|
|
272
|
+
# CT: standard LQG predictor form
|
|
273
|
+
Ac = A - L @ C2 - B2 @ F + L @ D22 @ F
|
|
274
|
+
Bc = L
|
|
275
|
+
Cc = -F
|
|
276
|
+
Dc = np.zeros((n_ctrl, n_meas))
|
|
277
|
+
|
|
278
|
+
if not is_discrete:
|
|
279
|
+
K = sig.StateSpace(Ac, Bc, Cc, Dc)
|
|
280
|
+
else:
|
|
281
|
+
K = sig.StateSpace(Ac, Bc, Cc, Dc, dt=dt)
|
|
282
|
+
|
|
283
|
+
# ---- Compute H2-norm ----
|
|
284
|
+
try:
|
|
285
|
+
cl = _lft(sys_ss, K, n_meas, n_ctrl, dt)
|
|
286
|
+
h2norm = _h2norm_ss(cl, dt)
|
|
287
|
+
except Exception:
|
|
288
|
+
h2norm = float('nan')
|
|
289
|
+
|
|
290
|
+
return K, h2norm
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def hinfreg(sys_ss, n_meas=1, n_ctrl=1, tol=1e-4, gamma_tol=1e-4, verbose=False):
|
|
294
|
+
"""
|
|
295
|
+
H-infinity optimal controller for an LTI system (state-space).
|
|
296
|
+
|
|
297
|
+
Uses gamma-iteration with ARE solutions.
|
|
298
|
+
|
|
299
|
+
Parameters
|
|
300
|
+
----------
|
|
301
|
+
sys_ss : scipy.signal.StateSpace or (A,B,C,D) tuple
|
|
302
|
+
n_meas : int
|
|
303
|
+
n_ctrl : int
|
|
304
|
+
tol : float
|
|
305
|
+
gamma_tol : float
|
|
306
|
+
Tolerance on gamma bisection.
|
|
307
|
+
verbose : bool
|
|
308
|
+
|
|
309
|
+
Returns
|
|
310
|
+
-------
|
|
311
|
+
K : scipy.signal.StateSpace
|
|
312
|
+
H-infinity optimal controller.
|
|
313
|
+
gamma : float
|
|
314
|
+
Optimal H-infinity norm.
|
|
315
|
+
"""
|
|
316
|
+
try:
|
|
317
|
+
import scipy.signal as sig
|
|
318
|
+
import scipy.linalg as la
|
|
319
|
+
except ImportError:
|
|
320
|
+
raise ImportError("scipy is required.")
|
|
321
|
+
|
|
322
|
+
if isinstance(sys_ss, sig.StateSpace):
|
|
323
|
+
A, B, C, D = sys_ss.A, sys_ss.B, sys_ss.C, sys_ss.D
|
|
324
|
+
dt = sys_ss.dt
|
|
325
|
+
else:
|
|
326
|
+
A, B, C, D = sys_ss
|
|
327
|
+
dt = None
|
|
328
|
+
|
|
329
|
+
is_discrete = (dt is not None and dt != 0)
|
|
330
|
+
|
|
331
|
+
if is_discrete:
|
|
332
|
+
# `try_hinf` below only ever calls scipy's *continuous*-time
|
|
333
|
+
# solve_continuous_are — there is no discrete-time branch (unlike
|
|
334
|
+
# h2reg, which correctly branches on is_discrete for its own AREs).
|
|
335
|
+
# Deriving a separate discrete-time 4-block bounded-real Riccati
|
|
336
|
+
# formulation from scratch would be a large, hard-to-verify change.
|
|
337
|
+
# Instead, reuse the same bilinear (Tustin) DT->CT->DT wrapping that
|
|
338
|
+
# `_sdahinf_ss` (directsd/design/polynomial.py) already relies on for
|
|
339
|
+
# exactly this reason: convert to an equivalent CT plant, run the
|
|
340
|
+
# (already correct) continuous synthesis below via a recursive call,
|
|
341
|
+
# then convert the resulting controller back to discrete. The
|
|
342
|
+
# CT-domain gamma is only an O(T) approximation (same caveat
|
|
343
|
+
# `_sdahinf_ss` documents) — re-verify gamma against the actual
|
|
344
|
+
# discrete-time closed loop before returning, rather than trusting it.
|
|
345
|
+
T = float(dt)
|
|
346
|
+
sys_c = _bilin_d2c(sig.StateSpace(A, B, C, D, dt=dt), n_meas=n_meas, n_ctrl=n_ctrl)
|
|
347
|
+
K_c, _ = hinfreg(sys_c, n_meas, n_ctrl, tol, gamma_tol, verbose)
|
|
348
|
+
K_d = _bilin_c2d(K_c, T)
|
|
349
|
+
try:
|
|
350
|
+
cl = _lft(sig.StateSpace(A, B, C, D, dt=dt), K_d, n_meas, n_ctrl, dt)
|
|
351
|
+
gamma_d = _hinf_freq(cl.A, cl.B, cl.C, cl.D, is_discrete=True)
|
|
352
|
+
except Exception:
|
|
353
|
+
gamma_d = float('nan')
|
|
354
|
+
return K_d, gamma_d
|
|
355
|
+
|
|
356
|
+
# Minimal realization (see h2reg for the same fix and full rationale): a
|
|
357
|
+
# non-minimal GeneralizedPlant construction leaves uncontrollable/
|
|
358
|
+
# unobservable marginal modes that make the ARE solves below fail
|
|
359
|
+
# regardless of regularization.
|
|
360
|
+
A, B, C, D = Minreal.ss(A, B, C, D)
|
|
361
|
+
sys_ss = sig.StateSpace(A, B, C, D)
|
|
362
|
+
|
|
363
|
+
nout, nin = C.shape[0], B.shape[1]
|
|
364
|
+
i1 = nin - n_ctrl
|
|
365
|
+
o1 = nout - n_meas
|
|
366
|
+
|
|
367
|
+
B1 = B[:, :i1]; B2 = B[:, i1:]
|
|
368
|
+
C1 = C[:o1, :]; C2 = C[o1:, :]
|
|
369
|
+
D11 = D[:o1, :i1]; D12 = D[:o1, i1:]
|
|
370
|
+
D21 = D[o1:, :i1]; D22 = D[o1:, i1:]
|
|
371
|
+
|
|
372
|
+
# Initial gamma estimate: frequency-sweep Hinf bound on D11 + C1*(zI-A)^-1*B1
|
|
373
|
+
# This is always positive (unlike H2 which can be 0 for non-strictly-proper plants).
|
|
374
|
+
# (is_discrete is always False here — the discrete case returns early above.)
|
|
375
|
+
try:
|
|
376
|
+
gamma1 = max(_hinf_freq(A, B1, C1, D11, is_discrete=False) * 2.0, tol * 10)
|
|
377
|
+
except Exception:
|
|
378
|
+
# Fallback: use H2 estimate
|
|
379
|
+
K0_h2, h2n = h2reg(sys_ss, n_meas, n_ctrl, tol)
|
|
380
|
+
gamma1 = max(h2n * 2.0, tol * 10)
|
|
381
|
+
|
|
382
|
+
K0, _ = h2reg(sys_ss, n_meas, n_ctrl, tol)
|
|
383
|
+
|
|
384
|
+
def try_hinf(gamma):
|
|
385
|
+
"""Try to solve Hinf ARE for given gamma. Returns (K, success)."""
|
|
386
|
+
g2 = gamma ** 2
|
|
387
|
+
try:
|
|
388
|
+
# R1/R2 sign convention: solve_continuous_are(A,B,Q,R) solves
|
|
389
|
+
# A'X+XA-XBR^{-1}B'X+Q=0. The standard H-inf "X-infinity" ARE is
|
|
390
|
+
# A'X+XA+C1'C1 - X*B2*B2'*X + (1/g^2)*X*B1*B1'*X = 0 (verified by
|
|
391
|
+
# taking the g->inf limit, which must reduce to the standard LQR
|
|
392
|
+
# ARE A'X+XA+C1'C1-XB2B2'X=0 — a MINUS sign on B2, matching
|
|
393
|
+
# h2reg's own ARE). With B=[B1,B2], this needs
|
|
394
|
+
# B*R^{-1}*B' = B2B2' - (1/g^2)B1B1', i.e. R = diag(-g^2*I, +I) —
|
|
395
|
+
# NOT diag(+g^2*I, -I) as this previously read. The old sign
|
|
396
|
+
# convention meant the ARE being solved was never the real H-inf
|
|
397
|
+
# ARE, so no gamma could ever produce a valid PSD X (confirmed:
|
|
398
|
+
# for the demo_h2hinf plant, eig(X) had a persistent negative
|
|
399
|
+
# eigenvalue at every gamma from 10 to 234778 with the old signs;
|
|
400
|
+
# every gamma from 10 to 10000 gives a clean PSD X with the fix).
|
|
401
|
+
# R2 is the dual (filter) ARE and has the identical bug/fix.
|
|
402
|
+
R1 = np.block([[-g2 * np.eye(i1), np.zeros((i1, n_ctrl))],
|
|
403
|
+
[np.zeros((n_ctrl, i1)), np.eye(n_ctrl)]])
|
|
404
|
+
Q1 = C1.T @ C1
|
|
405
|
+
X = la.solve_continuous_are(A, np.hstack([B1, B2]), Q1, R1)
|
|
406
|
+
R2 = np.block([[-g2 * np.eye(o1), np.zeros((o1, n_meas))],
|
|
407
|
+
[np.zeros((n_meas, o1)), np.eye(n_meas)]])
|
|
408
|
+
Q2 = B1 @ B1.T
|
|
409
|
+
Y = la.solve_continuous_are(A.T, np.vstack([C1, C2]).T, Q2, R2)
|
|
410
|
+
|
|
411
|
+
eig_X = np.linalg.eigvals(X)
|
|
412
|
+
eig_Y = np.linalg.eigvals(Y)
|
|
413
|
+
eig_XY = np.linalg.eigvals(X @ Y)
|
|
414
|
+
|
|
415
|
+
if np.any(eig_X < 0) or np.any(eig_Y < 0) or np.max(np.abs(eig_XY)) >= g2:
|
|
416
|
+
return None, False
|
|
417
|
+
|
|
418
|
+
# Build controller
|
|
419
|
+
F = -la.solve(np.eye(n_ctrl), (D12.T @ C1 + B2.T @ X))
|
|
420
|
+
L = -(B1 @ D21.T + Y @ C2.T) @ la.inv(np.eye(n_meas))
|
|
421
|
+
Z = la.inv(np.eye(A.shape[0]) - Y @ X / g2)
|
|
422
|
+
Ac = A + B2 @ F + Z @ L @ C2 + Z @ L @ D22 @ F
|
|
423
|
+
Bc = Z @ L
|
|
424
|
+
Cc = F
|
|
425
|
+
Dc = np.zeros((n_ctrl, n_meas))
|
|
426
|
+
K_inf = sig.StateSpace(Ac, Bc, Cc, Dc)
|
|
427
|
+
return K_inf, True
|
|
428
|
+
except Exception:
|
|
429
|
+
return None, False
|
|
430
|
+
|
|
431
|
+
# Bisection
|
|
432
|
+
gamma = gamma1
|
|
433
|
+
K_best = K0
|
|
434
|
+
converged = False
|
|
435
|
+
for _ in range(50):
|
|
436
|
+
K_try, ok = try_hinf(gamma)
|
|
437
|
+
if ok:
|
|
438
|
+
K_best = K_try
|
|
439
|
+
gamma1 = gamma
|
|
440
|
+
gamma = gamma * 0.9
|
|
441
|
+
converged = True
|
|
442
|
+
else:
|
|
443
|
+
gamma = gamma * 1.1
|
|
444
|
+
if verbose:
|
|
445
|
+
print(f"gamma = {gamma:.6f}, ok = {ok}")
|
|
446
|
+
if abs(gamma - gamma1) / (abs(gamma1) + 1e-10) < gamma_tol:
|
|
447
|
+
break
|
|
448
|
+
|
|
449
|
+
if not converged:
|
|
450
|
+
# Previously this silently returned (K0, gamma1) — the *H2* controller
|
|
451
|
+
# and the untouched initial gamma guess — indistinguishable from a
|
|
452
|
+
# genuine converged result to the caller. That masked a real failure:
|
|
453
|
+
# `try_hinf`'s validity check (eig_X>=0, eig_Y>=0, max|eig(X@Y)|<gamma^2)
|
|
454
|
+
# never held for *any* gamma tried. Observed cause for at least one
|
|
455
|
+
# plant: the control ARE (`X`) has no PSD solution because a marginal
|
|
456
|
+
# (integrator) mode is essentially uncontrollable through B2 — no
|
|
457
|
+
# amount of gamma-adjustment fixes that, it's a structural
|
|
458
|
+
# stabilizability issue the standard 4-block Riccati approach can't
|
|
459
|
+
# handle. MATLAB's hinfreg.m has a fallback path for exactly this
|
|
460
|
+
# (`hinfone`/`hinfone1`/`care1`/`care2` in dsdsspace/private/, not yet
|
|
461
|
+
# ported here). Raise clearly so callers (e.g. `_sdahinf_ss`) fall
|
|
462
|
+
# back honestly instead of reporting a mislabeled H2 solution as if
|
|
463
|
+
# it were H-infinity optimal.
|
|
464
|
+
raise np.linalg.LinAlgError(
|
|
465
|
+
"hinfreg: gamma-iteration never found a valid Hinf ARE solution "
|
|
466
|
+
"(control or filter Riccati has no PSD solution for any gamma "
|
|
467
|
+
"tried) — likely a stabilizability/detectability degeneracy the "
|
|
468
|
+
"standard 4-block Riccati approach can't resolve for this plant."
|
|
469
|
+
)
|
|
470
|
+
|
|
471
|
+
return K_best, gamma1
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
def sdh2reg(plant, T, n_meas=1, n_ctrl=1, tol=1e-4):
|
|
475
|
+
"""
|
|
476
|
+
H2-optimal controller for a sampled-data system (state-space lifting).
|
|
477
|
+
|
|
478
|
+
Parameters
|
|
479
|
+
----------
|
|
480
|
+
plant : scipy.signal.StateSpace
|
|
481
|
+
Continuous-time generalized plant.
|
|
482
|
+
T : float
|
|
483
|
+
Sampling period.
|
|
484
|
+
n_meas, n_ctrl : int
|
|
485
|
+
tol : float
|
|
486
|
+
|
|
487
|
+
Returns
|
|
488
|
+
-------
|
|
489
|
+
K : scipy.signal.StateSpace (discrete)
|
|
490
|
+
h2norm : float
|
|
491
|
+
"""
|
|
492
|
+
try:
|
|
493
|
+
import scipy.signal as sig
|
|
494
|
+
except ImportError:
|
|
495
|
+
raise ImportError("scipy is required.")
|
|
496
|
+
|
|
497
|
+
# Discretize the generalized plant using ZOH lifting (sdgh2mod equivalent)
|
|
498
|
+
plant_d = plant.to_discrete(T, method='zoh')
|
|
499
|
+
K, h2n = h2reg(plant_d, n_meas, n_ctrl, tol)
|
|
500
|
+
return K, h2n
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
def sdhinfreg(plant, T, n_meas=1, n_ctrl=1, gamma_tol=1e-4, verbose=False):
|
|
504
|
+
"""
|
|
505
|
+
H-infinity optimal controller for a sampled-data system.
|
|
506
|
+
|
|
507
|
+
Parameters
|
|
508
|
+
----------
|
|
509
|
+
plant : scipy.signal.StateSpace
|
|
510
|
+
Continuous-time generalized plant.
|
|
511
|
+
T : float
|
|
512
|
+
Sampling period.
|
|
513
|
+
n_meas, n_ctrl : int
|
|
514
|
+
gamma_tol : float
|
|
515
|
+
verbose : bool
|
|
516
|
+
|
|
517
|
+
Returns
|
|
518
|
+
-------
|
|
519
|
+
K : scipy.signal.StateSpace (discrete)
|
|
520
|
+
gamma : float
|
|
521
|
+
"""
|
|
522
|
+
try:
|
|
523
|
+
import scipy.signal as sig
|
|
524
|
+
except ImportError:
|
|
525
|
+
raise ImportError("scipy is required.")
|
|
526
|
+
|
|
527
|
+
plant_d = plant.to_discrete(T, method='zoh')
|
|
528
|
+
K, gamma = hinfreg(plant_d, n_meas, n_ctrl,
|
|
529
|
+
gamma_tol=gamma_tol, verbose=verbose)
|
|
530
|
+
return K, gamma
|
|
531
|
+
|
|
532
|
+
|
|
533
|
+
def sdfast(plant, T):
|
|
534
|
+
"""
|
|
535
|
+
Fast (exact) discretization of a sampled-data system.
|
|
536
|
+
|
|
537
|
+
Computes the discrete-time equivalent of a continuous-time state-space
|
|
538
|
+
model using matrix exponential (Van Loan's method).
|
|
539
|
+
|
|
540
|
+
Parameters
|
|
541
|
+
----------
|
|
542
|
+
plant : scipy.signal.StateSpace
|
|
543
|
+
Continuous-time state-space model.
|
|
544
|
+
T : float
|
|
545
|
+
Sampling period.
|
|
546
|
+
|
|
547
|
+
Returns
|
|
548
|
+
-------
|
|
549
|
+
plant_d : scipy.signal.StateSpace
|
|
550
|
+
Discrete-time equivalent.
|
|
551
|
+
"""
|
|
552
|
+
try:
|
|
553
|
+
import scipy.signal as sig
|
|
554
|
+
import scipy.linalg as la
|
|
555
|
+
except ImportError:
|
|
556
|
+
raise ImportError("scipy is required.")
|
|
557
|
+
|
|
558
|
+
if isinstance(plant, sig.StateSpace):
|
|
559
|
+
A, B, C, D = plant.A, plant.B, plant.C, plant.D
|
|
560
|
+
else:
|
|
561
|
+
raise TypeError("plant must be a scipy StateSpace")
|
|
562
|
+
|
|
563
|
+
n = A.shape[0]
|
|
564
|
+
nb = B.shape[1]
|
|
565
|
+
|
|
566
|
+
# Van Loan's method for exact ZOH discretization
|
|
567
|
+
M = np.zeros((n + nb, n + nb))
|
|
568
|
+
M[:n, :n] = A
|
|
569
|
+
M[:n, n:] = B
|
|
570
|
+
eM = la.expm(M * T)
|
|
571
|
+
|
|
572
|
+
Ad = eM[:n, :n]
|
|
573
|
+
Bd = eM[:n, n:]
|
|
574
|
+
Cd = C
|
|
575
|
+
Dd = D
|
|
576
|
+
|
|
577
|
+
return sig.StateSpace(Ad, Bd, Cd, Dd, dt=T)
|
|
578
|
+
|
|
579
|
+
|
|
580
|
+
def separss(sys_ss, n_meas=1, n_ctrl=1):
|
|
581
|
+
"""
|
|
582
|
+
Proper separation (state-space technique).
|
|
583
|
+
|
|
584
|
+
Separate the generalized plant into proper and improper parts
|
|
585
|
+
using state-space methods.
|
|
586
|
+
|
|
587
|
+
Parameters
|
|
588
|
+
----------
|
|
589
|
+
sys_ss : scipy.signal.StateSpace
|
|
590
|
+
n_meas, n_ctrl : int
|
|
591
|
+
|
|
592
|
+
Returns
|
|
593
|
+
-------
|
|
594
|
+
sys_prop : scipy.signal.StateSpace
|
|
595
|
+
Proper part.
|
|
596
|
+
sys_imp : np.ndarray
|
|
597
|
+
Improper (polynomial) part as D matrix.
|
|
598
|
+
"""
|
|
599
|
+
try:
|
|
600
|
+
import scipy.signal as sig
|
|
601
|
+
except ImportError:
|
|
602
|
+
raise ImportError("scipy is required.")
|
|
603
|
+
|
|
604
|
+
if isinstance(sys_ss, sig.StateSpace):
|
|
605
|
+
A, B, C, D = sys_ss.A, sys_ss.B, sys_ss.C, sys_ss.D
|
|
606
|
+
dt = sys_ss.dt
|
|
607
|
+
else:
|
|
608
|
+
raise TypeError("sys_ss must be a scipy StateSpace")
|
|
609
|
+
|
|
610
|
+
# Strictly proper part (zero D matrix)
|
|
611
|
+
D_improper = D.copy()
|
|
612
|
+
D_proper = np.zeros_like(D)
|
|
613
|
+
if dt is not None and dt != 0:
|
|
614
|
+
sys_prop = sig.StateSpace(A, B, C, D_proper, dt=dt)
|
|
615
|
+
else:
|
|
616
|
+
sys_prop = sig.StateSpace(A, B, C, D_proper)
|
|
617
|
+
|
|
618
|
+
return sys_prop, D_improper
|
|
619
|
+
|
|
620
|
+
|
|
621
|
+
# ---------------------------------------------------------------------------
|
|
622
|
+
# sdh2simple – simple H2-equivalent discrete model (no Delta correction)
|
|
623
|
+
# ---------------------------------------------------------------------------
|
|
624
|
+
|
|
625
|
+
def sdh2simple(plant, T, n_meas=1, n_ctrl=1):
|
|
626
|
+
"""
|
|
627
|
+
Simple H2-equivalent discrete model of a sampled-data system.
|
|
628
|
+
|
|
629
|
+
Port of sdh2simple.m (K. Polyakov). Discretises the loop+output channel
|
|
630
|
+
without the FR-operator Delta correction (simpler than sdgh2mod).
|
|
631
|
+
|
|
632
|
+
Parameters
|
|
633
|
+
----------
|
|
634
|
+
plant : scipy.signal.StateSpace
|
|
635
|
+
Continuous-time generalised plant.
|
|
636
|
+
T : float
|
|
637
|
+
Sampling period.
|
|
638
|
+
n_meas, n_ctrl : int
|
|
639
|
+
Dimensions of measurement output and control input.
|
|
640
|
+
|
|
641
|
+
Returns
|
|
642
|
+
-------
|
|
643
|
+
dsys : scipy.signal.StateSpace (discrete, dt=T)
|
|
644
|
+
H2-equivalent discrete plant.
|
|
645
|
+
DP11, DP12, DP21, DP22 : scipy.signal.StateSpace
|
|
646
|
+
Sub-block discrete systems (only computed when requested).
|
|
647
|
+
"""
|
|
648
|
+
import scipy.linalg as la
|
|
649
|
+
import scipy.signal as sig
|
|
650
|
+
|
|
651
|
+
if isinstance(plant, sig.StateSpace):
|
|
652
|
+
A, B, C, D = plant.A, plant.B, plant.C, plant.D
|
|
653
|
+
else:
|
|
654
|
+
raise TypeError("plant must be a scipy.signal.StateSpace")
|
|
655
|
+
|
|
656
|
+
nout, nin = C.shape[0], B.shape[1]
|
|
657
|
+
i1 = nin - n_ctrl
|
|
658
|
+
o1 = nout - n_meas
|
|
659
|
+
if i1 < 1:
|
|
660
|
+
raise ValueError("No disturbance inputs (i1 < 1)")
|
|
661
|
+
if o1 < 1:
|
|
662
|
+
raise ValueError("No performance outputs (o1 < 1)")
|
|
663
|
+
|
|
664
|
+
B1 = B[:, :i1]; B2 = B[:, i1:]
|
|
665
|
+
C1 = C[:o1, :]; C2 = C[o1:, :]
|
|
666
|
+
D12 = D[:o1, i1:]
|
|
667
|
+
|
|
668
|
+
n = A.shape[0]
|
|
669
|
+
i2 = n_ctrl
|
|
670
|
+
|
|
671
|
+
# ── Loop + output discretisation via Van Loan ────────────────────────────
|
|
672
|
+
from directsd.design.lifting import _van_loan_output
|
|
673
|
+
C1d, D12d, Ad, B2d = _van_loan_output(A, B2, C1, D12, T)
|
|
674
|
+
|
|
675
|
+
# Scale by 1/√T (H2-norm convention, matching sdh2simple.m line 74)
|
|
676
|
+
sqrtT = np.sqrt(T)
|
|
677
|
+
C1d = C1d / sqrtT
|
|
678
|
+
D12d = D12d / sqrtT
|
|
679
|
+
|
|
680
|
+
B1d = B1 # sdh2simple uses original B1 (no Gramian discretisation)
|
|
681
|
+
|
|
682
|
+
i1d = i1
|
|
683
|
+
o1d = C1d.shape[0]
|
|
684
|
+
Bd = np.hstack([B1d, B2d])
|
|
685
|
+
Cd = np.vstack([C1d, C2])
|
|
686
|
+
Dd = np.block([[np.zeros((o1d, i1d)), D12d ],
|
|
687
|
+
[np.zeros((n_meas, i1d + i2)) ]])
|
|
688
|
+
|
|
689
|
+
dsys = sig.StateSpace(Ad, Bd, Cd, Dd, dt=T)
|
|
690
|
+
|
|
691
|
+
# Optional sub-block systems
|
|
692
|
+
DP11 = sig.StateSpace(Ad, B1d, C1d, np.zeros((o1d, i1d)), dt=T)
|
|
693
|
+
DP12 = sig.StateSpace(Ad, B2d, C1d, D12d, dt=T)
|
|
694
|
+
DP21 = sig.StateSpace(Ad, B1d, C2, np.zeros((n_meas, i1d)), dt=T)
|
|
695
|
+
DP22 = sig.StateSpace(Ad, B2d, C2, np.zeros((n_meas, i2)), dt=T)
|
|
696
|
+
|
|
697
|
+
return dsys, DP11, DP12, DP21, DP22
|
|
698
|
+
|
|
699
|
+
|
|
700
|
+
# ---------------------------------------------------------------------------
|
|
701
|
+
# sdgh2mod – generalised H2-equivalent discrete model (with Delta correction)
|
|
702
|
+
# ---------------------------------------------------------------------------
|
|
703
|
+
|
|
704
|
+
def sdgh2mod(plant, T, n_meas=1, n_ctrl=1):
|
|
705
|
+
"""
|
|
706
|
+
Generalised H2-equivalent discrete model of a sampled-data system.
|
|
707
|
+
|
|
708
|
+
Port of sdgh2mod.m (K. Polyakov) / Hagiwara-Araki FR-operator method.
|
|
709
|
+
Includes the Delta feedthrough correction for exact H2-norm equivalence.
|
|
710
|
+
|
|
711
|
+
Parameters
|
|
712
|
+
----------
|
|
713
|
+
plant : scipy.signal.StateSpace
|
|
714
|
+
Continuous-time generalised plant.
|
|
715
|
+
T : float
|
|
716
|
+
Sampling period.
|
|
717
|
+
n_meas, n_ctrl : int
|
|
718
|
+
|
|
719
|
+
Returns
|
|
720
|
+
-------
|
|
721
|
+
dsys : scipy.signal.StateSpace (discrete)
|
|
722
|
+
H2-equivalent model (Delta zeroed out).
|
|
723
|
+
gamma : float
|
|
724
|
+
Additional H2-cost term. Full cost: J = sqrt(‖dsys‖²_H2 + gamma).
|
|
725
|
+
dsys_delta : scipy.signal.StateSpace
|
|
726
|
+
Variant including the Delta feedthrough.
|
|
727
|
+
DP11, DP12, DP21, DP22, DP0 : scipy.signal.StateSpace
|
|
728
|
+
Sub-block discrete systems.
|
|
729
|
+
"""
|
|
730
|
+
import scipy.signal as sig
|
|
731
|
+
from directsd.design.lifting import lift_h2, _van_loan_gram
|
|
732
|
+
|
|
733
|
+
if not isinstance(plant, sig.StateSpace):
|
|
734
|
+
raise TypeError("plant must be a scipy.signal.StateSpace")
|
|
735
|
+
|
|
736
|
+
dsys, gamma, dsys_delta = lift_h2(plant, T, n_meas, n_ctrl)
|
|
737
|
+
|
|
738
|
+
A = plant.A; B = plant.B; C = plant.C; D = plant.D
|
|
739
|
+
nout, nin = C.shape[0], B.shape[1]
|
|
740
|
+
i1 = nin - n_ctrl
|
|
741
|
+
o1 = nout - n_meas
|
|
742
|
+
|
|
743
|
+
B1 = B[:, :i1]; B2 = B[:, i1:]
|
|
744
|
+
C1 = C[:o1, :]; C2 = C[o1:, :]
|
|
745
|
+
D12 = D[:o1, i1:]
|
|
746
|
+
|
|
747
|
+
n = A.shape[0]
|
|
748
|
+
i2 = n_ctrl
|
|
749
|
+
|
|
750
|
+
# Re-extract discretised sub-matrices from dsys_delta
|
|
751
|
+
Ad = dsys_delta.A
|
|
752
|
+
Bd = dsys_delta.B
|
|
753
|
+
Dd_delta = dsys_delta.D
|
|
754
|
+
|
|
755
|
+
BB1d, _ = _van_loan_gram(A, B1, T)
|
|
756
|
+
from directsd.design.lifting import _rectchol, _van_loan_output
|
|
757
|
+
B1d = _rectchol(BB1d).T
|
|
758
|
+
i1d = B1d.shape[1]
|
|
759
|
+
|
|
760
|
+
C1d_D12d = dsys_delta.C[:o1, :] if dsys_delta.C.shape[0] > n_meas else dsys_delta.C
|
|
761
|
+
C1d = C1d_D12d
|
|
762
|
+
D12d = Dd_delta[:o1, i1d:]
|
|
763
|
+
Delta = Dd_delta[:o1, :i1d]
|
|
764
|
+
|
|
765
|
+
B2d = Bd[:n, i1d:]
|
|
766
|
+
|
|
767
|
+
o1d = C1d.shape[0] if C1d.ndim == 2 else 1
|
|
768
|
+
|
|
769
|
+
# Reassemble sub-block systems using discretised matrices from lift_h2
|
|
770
|
+
_zeros = lambda r, c: np.zeros((r, c))
|
|
771
|
+
DP11 = sig.StateSpace(Ad, B1d, C1d, Delta, dt=T)
|
|
772
|
+
DP12 = sig.StateSpace(Ad, B2d, C1d, D12d, dt=T)
|
|
773
|
+
DP21 = sig.StateSpace(Ad, B1d, C2, _zeros(n_meas, i1d), dt=T)
|
|
774
|
+
DP22 = sig.StateSpace(Ad, B2d, C2, _zeros(n_meas, i2), dt=T)
|
|
775
|
+
DP0 = sig.StateSpace(Ad, B1[:n, :] if B1.shape[0] >= n else B1,
|
|
776
|
+
C1d, _zeros(o1d, i1), dt=T)
|
|
777
|
+
|
|
778
|
+
return dsys, gamma, dsys_delta, DP11, DP12, DP21, DP22, DP0
|
|
779
|
+
|
|
780
|
+
|
|
781
|
+
# ---------------------------------------------------------------------------
|
|
782
|
+
# sdnorm – sampled-data norm dispatcher
|
|
783
|
+
# ---------------------------------------------------------------------------
|
|
784
|
+
|
|
785
|
+
def sdnorm(plant, K, norm_type='gh2'):
|
|
786
|
+
"""
|
|
787
|
+
Sampled-data norm of the closed-loop system.
|
|
788
|
+
|
|
789
|
+
Port of sdnorm.m (K. Polyakov).
|
|
790
|
+
|
|
791
|
+
Parameters
|
|
792
|
+
----------
|
|
793
|
+
plant : scipy.signal.StateSpace
|
|
794
|
+
Continuous-time generalised plant.
|
|
795
|
+
K : scipy.signal.StateSpace (discrete)
|
|
796
|
+
Discrete-time controller.
|
|
797
|
+
norm_type : str
|
|
798
|
+
'gh2' - generalised H2-norm (default)
|
|
799
|
+
'sh2' - simple H2-norm
|
|
800
|
+
'inf' - Hinf-norm (L2-induced)
|
|
801
|
+
'ainf' - associated Hinf-norm
|
|
802
|
+
|
|
803
|
+
Returns
|
|
804
|
+
-------
|
|
805
|
+
N : float
|
|
806
|
+
Requested norm.
|
|
807
|
+
"""
|
|
808
|
+
import scipy.signal as sig
|
|
809
|
+
import scipy.linalg as la
|
|
810
|
+
|
|
811
|
+
if not hasattr(K, 'dt') or K.dt is None:
|
|
812
|
+
raise TypeError("K must be a discrete-time StateSpace")
|
|
813
|
+
T = float(K.dt)
|
|
814
|
+
|
|
815
|
+
n_ctrl = K.outputs if hasattr(K, 'outputs') else K.C.shape[0]
|
|
816
|
+
n_meas = K.inputs if hasattr(K, 'inputs') else K.B.shape[1]
|
|
817
|
+
|
|
818
|
+
tol = 1e-3
|
|
819
|
+
|
|
820
|
+
# ── Simple H2-norm ───────────────────────────────────────────────────────
|
|
821
|
+
if norm_type == 'sh2':
|
|
822
|
+
dsys, *_ = sdh2simple(plant, T, n_meas, n_ctrl)
|
|
823
|
+
# Closed-loop via LFT
|
|
824
|
+
dcl = _lft(dsys, K, n_meas, n_ctrl, dt=T)
|
|
825
|
+
N = float(np.sqrt(T)) * _h2norm_ss(dcl, dt=T)
|
|
826
|
+
return N
|
|
827
|
+
|
|
828
|
+
# ── Classical Hinf-norm ──────────────────────────────────────────────────
|
|
829
|
+
if norm_type == 'inf':
|
|
830
|
+
from directsd.analysis.norms import sdhinorm
|
|
831
|
+
return sdhinorm(plant, K)
|
|
832
|
+
|
|
833
|
+
# ── Generalised H2-norm (default) ────────────────────────────────────────
|
|
834
|
+
if norm_type == 'gh2':
|
|
835
|
+
dsys, gamma, *_ = sdgh2mod(plant, T, n_meas, n_ctrl)
|
|
836
|
+
dcl = _lft(dsys, K, n_meas, n_ctrl, dt=T)
|
|
837
|
+
h2cl = _h2norm_ss(dcl, dt=T)
|
|
838
|
+
N = float(np.sqrt(max(h2cl ** 2 + gamma, 0.0)))
|
|
839
|
+
return N
|
|
840
|
+
|
|
841
|
+
# ── Associated Hinf-norm ─────────────────────────────────────────────────
|
|
842
|
+
if norm_type == 'ainf':
|
|
843
|
+
dsys, gamma, dsys_delta, G11, *_ = sdgh2mod(plant, T, n_meas, n_ctrl)
|
|
844
|
+
dcl_delta = _lft(dsys_delta, K, n_meas, n_ctrl, dt=T)
|
|
845
|
+
# Use H2 as proxy for AHinf (full AHinf needs sdhinorm on a modified sys)
|
|
846
|
+
N = float(np.sqrt(T)) * _h2norm_ss(dcl_delta, dt=T)
|
|
847
|
+
return N
|
|
848
|
+
|
|
849
|
+
raise ValueError(f"Unknown norm type '{norm_type}'. Use 'gh2', 'sh2', 'inf', or 'ainf'.")
|
|
850
|
+
|
|
851
|
+
|
|
852
|
+
# ---------------------------------------------------------------------------
|
|
853
|
+
# sdsim – impulse response simulation of sampled-data system
|
|
854
|
+
# ---------------------------------------------------------------------------
|
|
855
|
+
|
|
856
|
+
def sdsim(plant, K, T_max, n_meas=1, n_ctrl=1):
|
|
857
|
+
"""
|
|
858
|
+
Impulse response of the standard sampled-data closed-loop system.
|
|
859
|
+
|
|
860
|
+
Port of sdsim.m (K. Polyakov). Simulates the CT plant + DT controller
|
|
861
|
+
using a zero-order hold on the controller output and a step-to-impulse
|
|
862
|
+
conversion (divides CT input by s, i.e. uses a step input instead).
|
|
863
|
+
|
|
864
|
+
Parameters
|
|
865
|
+
----------
|
|
866
|
+
plant : scipy.signal.StateSpace
|
|
867
|
+
Continuous-time generalised plant.
|
|
868
|
+
K : scipy.signal.StateSpace (discrete)
|
|
869
|
+
Discrete-time controller.
|
|
870
|
+
T_max : float
|
|
871
|
+
Simulation end time.
|
|
872
|
+
n_meas, n_ctrl : int
|
|
873
|
+
Plant partition dimensions.
|
|
874
|
+
|
|
875
|
+
Returns
|
|
876
|
+
-------
|
|
877
|
+
t : np.ndarray
|
|
878
|
+
Time vector.
|
|
879
|
+
y : np.ndarray
|
|
880
|
+
Performance output (z) over time.
|
|
881
|
+
"""
|
|
882
|
+
import scipy.signal as sig
|
|
883
|
+
import scipy.linalg as la
|
|
884
|
+
|
|
885
|
+
if not isinstance(plant, sig.StateSpace):
|
|
886
|
+
raise TypeError("plant must be a scipy.signal.StateSpace")
|
|
887
|
+
if not hasattr(K, 'dt') or K.dt is None:
|
|
888
|
+
raise TypeError("K must be a discrete-time StateSpace")
|
|
889
|
+
|
|
890
|
+
T = float(K.dt)
|
|
891
|
+
A = plant.A; B = plant.B; C = plant.C; D = plant.D
|
|
892
|
+
nout, nin = C.shape[0], B.shape[1]
|
|
893
|
+
i1 = nin - n_ctrl
|
|
894
|
+
o1 = nout - n_meas
|
|
895
|
+
|
|
896
|
+
B1 = B[:, :i1]; B2 = B[:, i1:]
|
|
897
|
+
C1 = C[:o1, :]; C2 = C[o1:, :]
|
|
898
|
+
D12 = D[:o1, i1:]
|
|
899
|
+
D21 = D[o1:, :i1]
|
|
900
|
+
D22 = D[o1:, i1:]
|
|
901
|
+
|
|
902
|
+
Ak = K.A; Bk = K.B; Ck = K.C; Dk = K.D
|
|
903
|
+
n = A.shape[0]
|
|
904
|
+
nk = Ak.shape[0]
|
|
905
|
+
|
|
906
|
+
Phi = la.expm(A * T)
|
|
907
|
+
Gamma1 = la.solve(A, (Phi - np.eye(n)) @ B1) if np.linalg.matrix_rank(A) == n \
|
|
908
|
+
else (la.expm(A * T) - np.eye(n)) @ np.linalg.pinv(A) @ B1
|
|
909
|
+
|
|
910
|
+
# Time grid at controller sample instants
|
|
911
|
+
N_steps = max(int(np.ceil(T_max / T)), 1)
|
|
912
|
+
t_out = []
|
|
913
|
+
y_out = []
|
|
914
|
+
|
|
915
|
+
# CT state x, controller state xk
|
|
916
|
+
x = np.zeros(n)
|
|
917
|
+
xk = np.zeros(nk)
|
|
918
|
+
u = np.zeros(n_ctrl) # ZOH controller output
|
|
919
|
+
|
|
920
|
+
Phi_A = la.expm(A * T)
|
|
921
|
+
# Precompute B2 ZOH matrix
|
|
922
|
+
try:
|
|
923
|
+
Gamma2 = la.solve(A, (Phi_A - np.eye(n))) @ B2
|
|
924
|
+
except Exception:
|
|
925
|
+
Gamma2 = np.linalg.pinv(A) @ (Phi_A - np.eye(n)) @ B2
|
|
926
|
+
|
|
927
|
+
# Step input (impulse = d/dt step) applied once at t=0
|
|
928
|
+
# The input w is a unit impulse scaled by 1 (matched to step via integration)
|
|
929
|
+
w_impulse = np.ones(i1)
|
|
930
|
+
|
|
931
|
+
for k in range(N_steps):
|
|
932
|
+
t_k = k * T
|
|
933
|
+
# Fine simulation within [t_k, t_k+T] for performance output
|
|
934
|
+
n_fine = max(int(20 * T / max(T_max / 200, 1e-10)), 4)
|
|
935
|
+
t_fine = np.linspace(t_k, t_k + T, n_fine, endpoint=False)
|
|
936
|
+
dt_fine = T / (n_fine - 1) if n_fine > 1 else T
|
|
937
|
+
|
|
938
|
+
for j, t_j in enumerate(t_fine):
|
|
939
|
+
# CT step: x evolves under A with B1*w (only at t=0) + B2*u
|
|
940
|
+
if k == 0 and j == 0:
|
|
941
|
+
x_step = Phi_A @ x + Gamma1 @ w_impulse + Gamma2 @ u
|
|
942
|
+
else:
|
|
943
|
+
x_step = x # placeholder; propagated below
|
|
944
|
+
|
|
945
|
+
# Propagate one full sample step
|
|
946
|
+
x_next = Phi_A @ x + Gamma1 @ (w_impulse if k == 0 else np.zeros(i1)) + Gamma2 @ u
|
|
947
|
+
|
|
948
|
+
# Performance output y1 = C1*x + D12*u (at sample instant)
|
|
949
|
+
y1 = C1 @ x + D12 @ u
|
|
950
|
+
t_out.append(t_k)
|
|
951
|
+
y_out.append(y1)
|
|
952
|
+
|
|
953
|
+
# Measurement output y2 = C2*x + D21*w + D22*u
|
|
954
|
+
y2 = C2 @ x + (D21 @ w_impulse if k == 0 else np.zeros(n_meas)) + D22 @ u
|
|
955
|
+
|
|
956
|
+
# Discrete controller update: xk+ = Ak*xk + Bk*y2, u = Ck*xk + Dk*y2
|
|
957
|
+
xk_next = Ak @ xk + Bk @ y2
|
|
958
|
+
u = Ck @ xk + Dk @ y2
|
|
959
|
+
|
|
960
|
+
x = x_next
|
|
961
|
+
xk = xk_next
|
|
962
|
+
|
|
963
|
+
t_arr = np.array(t_out)
|
|
964
|
+
y_arr = np.array(y_out)
|
|
965
|
+
return t_arr, y_arr
|
|
966
|
+
|
|
967
|
+
|
|
968
|
+
# ---- Helper functions ----
|
|
969
|
+
|
|
970
|
+
def _lft(sys_ss, K, n_meas, n_ctrl, dt):
|
|
971
|
+
"""Lower linear fractional transformation."""
|
|
972
|
+
import scipy.signal as sig
|
|
973
|
+
|
|
974
|
+
if isinstance(sys_ss, sig.StateSpace):
|
|
975
|
+
A, B, C, D = sys_ss.A, sys_ss.B, sys_ss.C, sys_ss.D
|
|
976
|
+
else:
|
|
977
|
+
A, B, C, D = sys_ss
|
|
978
|
+
|
|
979
|
+
nout, nin = C.shape[0], B.shape[1]
|
|
980
|
+
i1 = nin - n_ctrl
|
|
981
|
+
o1 = nout - n_meas
|
|
982
|
+
|
|
983
|
+
Ak, Bk, Ck, Dk = K.A, K.B, K.C, K.D
|
|
984
|
+
|
|
985
|
+
B2 = B[:, i1:]
|
|
986
|
+
C2 = C[o1:, :]
|
|
987
|
+
D22 = D[o1:, i1:]
|
|
988
|
+
D12 = D[:o1, i1:]
|
|
989
|
+
D21 = D[o1:, :i1]
|
|
990
|
+
B1 = B[:, :i1]
|
|
991
|
+
C1 = C[:o1, :]
|
|
992
|
+
D11 = D[:o1, :i1]
|
|
993
|
+
|
|
994
|
+
n = A.shape[0]
|
|
995
|
+
|
|
996
|
+
# Closed-loop state matrix
|
|
997
|
+
IminDkD22 = np.eye(n_ctrl) - Dk @ D22
|
|
998
|
+
Acl = np.block([
|
|
999
|
+
[A + B2 @ np.linalg.solve(IminDkD22, Dk @ C2),
|
|
1000
|
+
B2 @ np.linalg.solve(IminDkD22, Ck)],
|
|
1001
|
+
[Bk @ np.linalg.solve(np.eye(n_meas) - D22 @ Dk, C2),
|
|
1002
|
+
Ak + Bk @ np.linalg.solve(np.eye(n_meas) - D22 @ Dk, D22 @ Ck)]
|
|
1003
|
+
])
|
|
1004
|
+
|
|
1005
|
+
Bcl = np.vstack([
|
|
1006
|
+
B1 + B2 @ np.linalg.solve(IminDkD22, Dk @ D21),
|
|
1007
|
+
Bk @ np.linalg.solve(np.eye(n_meas) - D22 @ Dk, D21)
|
|
1008
|
+
])
|
|
1009
|
+
|
|
1010
|
+
Ccl = np.hstack([
|
|
1011
|
+
C1 + D12 @ np.linalg.solve(IminDkD22, Dk @ C2),
|
|
1012
|
+
D12 @ np.linalg.solve(IminDkD22, Ck)
|
|
1013
|
+
])
|
|
1014
|
+
|
|
1015
|
+
Dcl = D11 + D12 @ np.linalg.solve(IminDkD22, Dk @ D21)
|
|
1016
|
+
|
|
1017
|
+
is_dt = (dt is not None and dt != 0)
|
|
1018
|
+
if not is_dt:
|
|
1019
|
+
return sig.StateSpace(Acl, Bcl, Ccl, Dcl)
|
|
1020
|
+
else:
|
|
1021
|
+
return sig.StateSpace(Acl, Bcl, Ccl, Dcl, dt=dt)
|
|
1022
|
+
|
|
1023
|
+
|
|
1024
|
+
def _h2norm_ss(sys_ss, dt):
|
|
1025
|
+
"""Compute H2-norm from state-space.
|
|
1026
|
+
|
|
1027
|
+
For strictly stable systems: standard Lyapunov equation.
|
|
1028
|
+
For systems with marginal/unstable eigenvalues: checks observability
|
|
1029
|
+
of each non-stable mode via the PBH right eigenvector test (mirroring
|
|
1030
|
+
MATLAB's minreal before norm()).
|
|
1031
|
+
- Observable non-stable mode → H2 = ∞ (returns nan).
|
|
1032
|
+
- All non-stable modes unobservable → frequency-domain integration on
|
|
1033
|
+
the unit circle with half-point grid (avoids the cancelled-pole
|
|
1034
|
+
singularity at z=1, giving the correct finite H2).
|
|
1035
|
+
"""
|
|
1036
|
+
import scipy.linalg as la
|
|
1037
|
+
|
|
1038
|
+
A, B, C, D = sys_ss.A, sys_ss.B, sys_ss.C, sys_ss.D
|
|
1039
|
+
is_dt = (dt is not None and dt != 0)
|
|
1040
|
+
tol = 1e-6
|
|
1041
|
+
|
|
1042
|
+
eigs, V = la.eig(A)
|
|
1043
|
+
has_nonstable = False
|
|
1044
|
+
C_norm = np.linalg.norm(C.astype(complex), 'fro')
|
|
1045
|
+
unstab_tol = 1e-4 # modes with |λ| > 1+unstab_tol are "strictly unstable"
|
|
1046
|
+
for i, lam in enumerate(eigs):
|
|
1047
|
+
abs_lam = np.abs(lam) if is_dt else np.real(lam)
|
|
1048
|
+
boundary = 1.0 if is_dt else 0.0
|
|
1049
|
+
if abs_lam < boundary - tol:
|
|
1050
|
+
continue # clearly stable
|
|
1051
|
+
has_nonstable = True
|
|
1052
|
+
if abs_lam > boundary + unstab_tol:
|
|
1053
|
+
# Strictly unstable: check observability only.
|
|
1054
|
+
# (Reachability via B^T*conj(v) is an unreliable proxy for non-normal A;
|
|
1055
|
+
# using observability alone is conservative but safe.)
|
|
1056
|
+
v = V[:, i]; v = v / (np.linalg.norm(v) + 1e-300)
|
|
1057
|
+
if np.linalg.norm(C.astype(complex) @ v) > tol * (C_norm + 1e-10):
|
|
1058
|
+
return float('nan') # observable unstable mode → H2 = ∞
|
|
1059
|
+
# unobservable unstable mode: doesn't appear in output; H2 is finite
|
|
1060
|
+
|
|
1061
|
+
if has_nonstable:
|
|
1062
|
+
# Marginal (|λ|≈1) or unobservable-unstable modes present.
|
|
1063
|
+
# Frequency-domain integration on the unit circle handles cancelled poles
|
|
1064
|
+
# correctly and doesn't diverge for unobservable unstable modes.
|
|
1065
|
+
if is_dt:
|
|
1066
|
+
return _h2_freq(A, B, C, D)
|
|
1067
|
+
|
|
1068
|
+
try:
|
|
1069
|
+
if not is_dt:
|
|
1070
|
+
P = la.solve_continuous_lyapunov(A, -B @ B.T)
|
|
1071
|
+
h2sq = float(np.real(np.trace(C @ P @ C.T)))
|
|
1072
|
+
else:
|
|
1073
|
+
# Discrete H2 norm includes the direct-feedthrough term
|
|
1074
|
+
# (impulse response at k=0): ||G||² = tr(D·D') + tr(C·P·C').
|
|
1075
|
+
# Omitting tr(D·D') makes this disagree with an independent
|
|
1076
|
+
# closed-loop evaluation for any loop with nonzero feedthrough.
|
|
1077
|
+
P = la.solve_discrete_lyapunov(A, B @ B.T)
|
|
1078
|
+
h2sq = float(np.real(np.trace(C @ P @ C.T)))
|
|
1079
|
+
h2sq += float(np.real(np.trace(D @ D.T)))
|
|
1080
|
+
except Exception:
|
|
1081
|
+
h2sq = float('nan')
|
|
1082
|
+
|
|
1083
|
+
return float(np.sqrt(max(h2sq, 0.0)))
|