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.
Files changed (41) hide show
  1. directsd/__init__.py +141 -0
  2. directsd/analysis/__init__.py +4 -0
  3. directsd/analysis/charpol.py +89 -0
  4. directsd/analysis/errors.py +185 -0
  5. directsd/analysis/norms.py +630 -0
  6. directsd/design/__init__.py +9 -0
  7. directsd/design/convex.py +620 -0
  8. directsd/design/lifting.py +403 -0
  9. directsd/design/polynomial.py +5925 -0
  10. directsd/examples/__init__.py +0 -0
  11. directsd/examples/_common.py +36 -0
  12. directsd/examples/demos.py +1161 -0
  13. directsd/examples/examples.py +296 -0
  14. directsd/examples/help_examples.py +1043 -0
  15. directsd/glopt/__init__.py +4 -0
  16. directsd/glopt/advanced.py +1658 -0
  17. directsd/glopt/optimize.py +420 -0
  18. directsd/linalg/__init__.py +3 -0
  19. directsd/linalg/linsys.py +66 -0
  20. directsd/linalg/matrices.py +156 -0
  21. directsd/linalg/minreal.py +425 -0
  22. directsd/linalg/riccati.py +197 -0
  23. directsd/polynomial/__init__.py +11 -0
  24. directsd/polynomial/diophantine.py +426 -0
  25. directsd/polynomial/operations.py +247 -0
  26. directsd/polynomial/poln.py +742 -0
  27. directsd/polynomial/spectral.py +368 -0
  28. directsd/polynomial/transforms.py +124 -0
  29. directsd/polynomial/utils.py +449 -0
  30. directsd/sspace/__init__.py +4 -0
  31. directsd/sspace/design.py +1083 -0
  32. directsd/sspace/plant.py +198 -0
  33. directsd/tf/__init__.py +9 -0
  34. directsd/tf/interconnect.py +105 -0
  35. directsd/zpk/__init__.py +1 -0
  36. directsd/zpk/zpk.py +400 -0
  37. directsd_python-0.1.0.dist-info/METADATA +450 -0
  38. directsd_python-0.1.0.dist-info/RECORD +41 -0
  39. directsd_python-0.1.0.dist-info/WHEEL +5 -0
  40. directsd_python-0.1.0.dist-info/licenses/LICENSE +36 -0
  41. 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)))