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,403 @@
1
+ """
2
+ directsd.design.lifting
3
+ =======================
4
+ Lifting (FR-operator) for sampled-data systems.
5
+
6
+ Lifting maps the continuous-time generalized plant P(s) to an
7
+ *exact* discrete-time equivalent P_d that accounts for inter-sample
8
+ behaviour. The H2-optimal discrete controller for P_d is also
9
+ H2-optimal for the original sampled-data loop.
10
+
11
+ References
12
+ ----------
13
+ [1] T. Hagiwara & M. Araki, "FR-operator approach to H2-analysis and
14
+ synthesis of sampled-data systems," IEEE TAC, 40(8), 1995.
15
+ [2] T. Chen & B. Francis, "Optimal Sampled-Data Control Systems,"
16
+ Springer, 1995.
17
+ [3] C. F. Van Loan, "Computing integrals involving the matrix
18
+ exponential," IEEE TAC, 23(3), 1978.
19
+ """
20
+
21
+ import numpy as np
22
+ import scipy.linalg as la
23
+ import scipy.signal as sig
24
+ import warnings
25
+
26
+
27
+ # ── helpers ─────────────────────────────────────────────────────────────────
28
+
29
+ def _rectchol(M, n_cols=None):
30
+ """
31
+ Rectangular Cholesky factor: find R (n_cols × size) such that R'R ≈ M.
32
+ Uses an eigendecomposition for positive-semi-definite M.
33
+ When n_cols is None, automatically determines rank via threshold.
34
+ """
35
+ M = (M + M.T) / 2 # symmetrise
36
+ eigvals, eigvecs = la.eigh(M)
37
+ # Clip tiny negative eigenvalues caused by floating-point rounding
38
+ eigvals = np.maximum(eigvals, 0.0)
39
+ if n_cols is None:
40
+ # Keep only numerically significant eigenvalues (rank-revealing)
41
+ thresh = eigvals.max() * 1e-10 if eigvals.max() > 0 else 0.0
42
+ n_cols = max(1, int(np.sum(eigvals > thresh)))
43
+ # Keep largest n_cols eigenvalues
44
+ idx = np.argsort(eigvals)[::-1][:n_cols]
45
+ R = np.diag(np.sqrt(eigvals[idx])) @ eigvecs[:, idx].T
46
+ return R
47
+
48
+
49
+ def _van_loan_gram(A, B, T):
50
+ """
51
+ Van Loan's method (1978) to compute the *discrete input Gramian*
52
+
53
+ G_d = ∫_0^T e^{A τ} B B' e^{A' τ} dτ
54
+
55
+ via the matrix exponential of an augmented 2n×2n system:
56
+
57
+ M = expm([−A B B'; 0 A'] * T)
58
+
59
+ G_d = M[n:, n:]' @ M[:n, n:]
60
+ """
61
+ n = A.shape[0]
62
+ Znn = np.zeros((n, n))
63
+ E = np.block([[-A, B @ B.T],
64
+ [Znn, A.T ]]) * T
65
+ P = la.expm(E)
66
+ Phi_T = P[n:, n:].T # = e^{A T}
67
+ BB_d = Phi_T @ P[:n, n:] # = G_d
68
+ return BB_d, Phi_T
69
+
70
+
71
+ def _van_loan_output(A, B2, C1, D12, T):
72
+ """
73
+ Van Loan-style computation for the output Gramian integral.
74
+
75
+ Returns (C1d, D12d, Ad, B2d) where C1d is (o1×n) and D12d is (o1×i2).
76
+ """
77
+ n = A.shape[0]
78
+ i2 = B2.shape[1]
79
+ o1 = C1.shape[0]
80
+ nbar = n + i2
81
+
82
+ Abar = np.block([[A, B2 ],
83
+ [np.zeros((i2, n)), np.zeros((i2, i2)) ]])
84
+ C1D12 = np.hstack([C1, D12]) # (o1 × nbar)
85
+ Q = C1D12.T @ C1D12 # (nbar × nbar) ≥ 0
86
+
87
+ L = np.block([[-Abar.T, Q ],
88
+ [np.zeros((nbar, nbar)), Abar ]]) * T
89
+ M = la.expm(L)
90
+ M22 = M[nbar:, nbar:] # e^{Abar T}
91
+
92
+ CCDD = M22.T @ M[:nbar, nbar:] # nbar × nbar ≥ 0
93
+
94
+ # Rectangular Cholesky factor F such that F'F ≈ CCDD.
95
+ # We need F to be (o1 × nbar) so C1d = F[:, :n] and D12d = F[:, n:].
96
+ # Use eigendecomposition keeping the top o1 modes.
97
+ CCDD = (CCDD + CCDD.T) / 2
98
+ eigvals, eigvecs = la.eigh(CCDD)
99
+ eigvals = np.maximum(eigvals, 0.0)
100
+
101
+ # Keep all numerically significant eigenvalues (rank of CCDD can exceed o1)
102
+ n_keep = np.sum(eigvals > eigvals.max() * 1e-10)
103
+ idx = np.argsort(eigvals)[::-1][:n_keep]
104
+ F = np.diag(np.sqrt(eigvals[idx])) @ eigvecs[:, idx].T # (n_keep × nbar)
105
+
106
+ C1d = F[:, :n] # n_keep × n
107
+ D12d = F[:, n:] # n_keep × i2
108
+
109
+ Ad = M22[:n, :n]
110
+ B2d = M22[:n, n:]
111
+ return C1d, D12d, Ad, B2d
112
+
113
+
114
+ # ── Public API ───────────────────────────────────────────────────────────────
115
+
116
+ def lift_h2(plant_ss, T, n_meas=1, n_ctrl=1):
117
+ """
118
+ H2-lifting: construct the exact discrete-time H2-equivalent of a
119
+ continuous-time generalized plant (Hagiwara-Araki / Chen-Francis).
120
+
121
+ Parameters
122
+ ----------
123
+ plant_ss : scipy.signal.StateSpace
124
+ Continuous-time plant in *standard form*::
125
+
126
+ [z] [P11 P12] [w]
127
+ [y] = [P21 P22] [u]
128
+
129
+ Assumptions (same as MATLAB sdgh2mod):
130
+ • D11 ≈ 0 (no direct disturbance-to-output)
131
+ • D22 ≈ 0 (no direct control-to-measurement)
132
+ T : float
133
+ Sampling period.
134
+ n_meas : int
135
+ Number of measurement outputs (rows of P21/P22).
136
+ n_ctrl : int
137
+ Number of control inputs (cols of P12/P22).
138
+
139
+ Returns
140
+ -------
141
+ dsys : scipy.signal.StateSpace
142
+ Exact discrete-time H2-equivalent (without Δ correction term).
143
+ gamma : float
144
+ Additional H2-cost term due to continuous intersample signal energy
145
+ (from B1 channel). Full cost: J = ‖dsys‖²_H2 + gamma.
146
+ dsys_delta : scipy.signal.StateSpace
147
+ Variant that includes the Δ feedthrough block (FR-operator form).
148
+
149
+ Notes
150
+ -----
151
+ The user workflow is::
152
+
153
+ from directsd.design.lifting import lift_h2
154
+ from directsd import h2reg
155
+
156
+ plant_lifted, gamma, _ = lift_h2(plant_ss, T)
157
+ K, h2n = h2reg(plant_lifted, n_meas=1, n_ctrl=1)
158
+ total_cost = np.sqrt(h2n**2 + gamma)
159
+ """
160
+ if not isinstance(plant_ss, sig.StateSpace):
161
+ raise TypeError("plant_ss must be a scipy.signal.StateSpace")
162
+ if plant_ss.dt not in (None, 0):
163
+ raise ValueError("plant_ss must be a continuous-time system (dt=None or 0)")
164
+
165
+ A = plant_ss.A
166
+ B = plant_ss.B
167
+ C = plant_ss.C
168
+ D = plant_ss.D
169
+ n = A.shape[0]
170
+
171
+ nout, nin = C.shape[0], B.shape[1]
172
+ i1 = nin - n_ctrl
173
+ o1 = nout - n_meas
174
+
175
+ if i1 < 1:
176
+ raise ValueError("No disturbance inputs (i1 < 1)")
177
+ if o1 < 1:
178
+ raise ValueError("No performance outputs (o1 < 1)")
179
+
180
+ B1 = B[:, :i1]; B2 = B[:, i1:]
181
+ C1 = C[:o1, :]; C2 = C[o1:, :]
182
+ D11 = D[:o1, :i1]; D12 = D[:o1, i1:]
183
+ D21 = D[o1:, :i1]; D22 = D[o1:, i1:]
184
+
185
+ if np.linalg.norm(D11) > 1e-8 * (np.linalg.norm(D) + 1):
186
+ warnings.warn("D11 ≠ 0 – H2-lifting assumes D11 = 0")
187
+ if np.linalg.norm(D22) > 1e-8 * (np.linalg.norm(D) + 1):
188
+ warnings.warn("D22 ≠ 0 – H2-lifting assumes D22 = 0")
189
+
190
+ # ── Discretise B1 channel via Van Loan (input Gramian) ──────────────────
191
+ BB1d, Phi_T = _van_loan_gram(A, B1, T)
192
+ # Rectangular Cholesky: B1d B1d' = BB1d
193
+ B1d = _rectchol(BB1d).T # n × i1d (i1d ≥ i1)
194
+ i1d = B1d.shape[1]
195
+
196
+ # ── Discretise B2/C1 channel (output Gramian) ───────────────────────────
197
+ C1d, D12d, Ad, B2d = _van_loan_output(A, B2, C1, D12, T)
198
+
199
+ # Scale by 1/√T (H2-norm convention)
200
+ sqrtT = np.sqrt(T)
201
+ C1d = C1d / sqrtT
202
+ D12d = D12d / sqrtT
203
+
204
+ # ── Delta feedthrough (FR-operator correction) ──────────────────────────
205
+ # Δ satisfies: B1d @ Δ' @ [C1d D12d] = Q_correction
206
+ # Computed via pseudo-inverse (cf. frdelta in sdgh2mod.m)
207
+ nbar = n + n_ctrl
208
+ ah1 = np.block([[A, B1 @ B1.T ],
209
+ [np.zeros((n, n)), -A.T ]])
210
+ ah2 = np.block([[A, B2 ],
211
+ [np.zeros((n_ctrl, n + n_ctrl)) ]])
212
+ bh = np.block([[np.zeros((n, n + n_ctrl)) ],
213
+ [-C1.T @ C1, -C1.T @ D12 ]])
214
+ H_big = np.block([[ah1, bh ],
215
+ [np.zeros((n + n_ctrl, 2*n)), ah2 ]])
216
+ expH = la.expm(H_big * T)
217
+ ind_n = slice(n, 2*n)
218
+ PhiInvT = expH[ind_n, ind_n]
219
+ q_corr = (expH[:n, 2*n:]
220
+ + BB1d @ PhiInvT @ C1d.T @ np.hstack([C1d, D12d]))
221
+ # Δ = pinv(B1d) @ q_corr @ pinv([C1d D12d]')
222
+ C1dD12d = np.hstack([C1d, D12d])
223
+ Delta = (np.linalg.pinv(B1d) @ q_corr @ np.linalg.pinv(C1dD12d.T).T).T
224
+ Delta = Delta / sqrtT
225
+
226
+ # ── Assemble discrete systems ────────────────────────────────────────────
227
+ Bd = np.hstack([B1d, B2d])
228
+ Cd = np.vstack([C1d, C2])
229
+
230
+ # C1d is (o1_d × n), D12d is (o1_d × n_ctrl). o1_d may equal o1.
231
+ o1_d = C1d.shape[0]
232
+
233
+ Dd_plain = np.block([[np.zeros((o1_d, i1d)), D12d ],
234
+ [np.zeros((n_meas, i1d + n_ctrl)) ]])
235
+ Dd_delta = np.block([[Delta, D12d ],
236
+ [np.zeros((n_meas, Delta.shape[1] + n_ctrl)) ]])
237
+
238
+ # Handle non-zero D21
239
+ if np.linalg.norm(D21) > 1e-10:
240
+ Bd = np.hstack([B1d, np.zeros((n, i1)), B2d])
241
+ Dd_plain = np.block([[np.zeros((o1_d, i1d)), np.zeros((o1_d, i1)), D12d],
242
+ [np.zeros((n_meas, i1d)), D21, np.zeros((n_meas, n_ctrl))]])
243
+ Dd_delta = np.block([[Delta, np.zeros((o1_d, i1)), D12d],
244
+ [np.zeros((n_meas, Delta.shape[1])), D21, np.zeros((n_meas, n_ctrl))]])
245
+
246
+ dsys = sig.StateSpace(Ad, Bd, Cd, Dd_plain, dt=T)
247
+ dsys_delta = sig.StateSpace(Ad, Bd, Cd, Dd_delta, dt=T)
248
+
249
+ # ── Additional H2-cost term γ (inter-sample energy of B1 channel) ───────
250
+ # γ = (1/T) tr( B1' ∫ e^{A'τ} C1'C1 e^{Aτ} dτ B1 )
251
+ H_gamma = np.block([[-A.T, np.eye(n), np.zeros((n, n))],
252
+ [np.zeros((n,n)), -A.T, C1.T @ C1 ],
253
+ [np.zeros((n,n)), np.zeros((n,n)), A ]])
254
+ expHg = la.expm(H_gamma * T)
255
+ gamma = float(np.real(np.trace(
256
+ B1.T @ expHg[2*n:, 2*n:].T @ expHg[:n, 2*n:] @ B1
257
+ )) / T)
258
+
259
+ return dsys, gamma, dsys_delta
260
+
261
+
262
+ def lift_l2(plant_ss, T, n_meas=1, n_ctrl=1):
263
+ """
264
+ L2-lifting: simplified H2-equivalent (Chen-Francis, 1995).
265
+
266
+ Same as lift_h2 but omits the Δ correction (assumes D11 = D21 = D22 = 0
267
+ strictly). Faster and numerically cleaner for L2-optimal design.
268
+
269
+ Parameters
270
+ ----------
271
+ plant_ss, T, n_meas, n_ctrl : same as lift_h2.
272
+
273
+ Returns
274
+ -------
275
+ dsys : scipy.signal.StateSpace
276
+ Exact discrete-time L2-equivalent.
277
+ """
278
+ if not isinstance(plant_ss, sig.StateSpace):
279
+ raise TypeError("plant_ss must be a scipy.signal.StateSpace")
280
+
281
+ A = plant_ss.A; B = plant_ss.B
282
+ C = plant_ss.C; D = plant_ss.D
283
+ n = A.shape[0]
284
+
285
+ nout, nin = C.shape[0], B.shape[1]
286
+ i1 = nin - n_ctrl; o1 = nout - n_meas
287
+ B1 = B[:, :i1]; B2 = B[:, i1:]
288
+ C1 = C[:o1, :]; C2 = C[o1:, :]
289
+ D12 = D[:o1, i1:]
290
+
291
+ # Output channel (loop + output discretisation)
292
+ C1d, D12d, Ad, B2d = _van_loan_output(A, B2, C1, D12, T)
293
+ sqrtT = np.sqrt(T)
294
+ C1d = C1d / sqrtT
295
+ D12d = D12d / sqrtT
296
+
297
+ # Input channel: B1 is passed through as continuous (no lifting needed
298
+ # for L2 – impulse disturbances)
299
+ i1d = i1
300
+ B1d = B1
301
+
302
+ o1_d = C1d.shape[0]
303
+ Bd = np.hstack([B1d, B2d])
304
+ Cd = np.vstack([C1d, C2])
305
+ Dd = np.block([[np.zeros((o1_d, i1d)), D12d ],
306
+ [np.zeros((n_meas, i1d + n_ctrl)) ]])
307
+
308
+ return sig.StateSpace(Ad, Bd, Cd, Dd, dt=T)
309
+
310
+
311
+ def lft_dt(plant_ss_dt, K_ss_dt, n_meas=1, n_ctrl=1):
312
+ """
313
+ Lower linear fractional transformation: close the sampled-data loop.
314
+
315
+ Returns the DT closed-loop StateSpace from exogenous input w to
316
+ performance output z::
317
+
318
+ Fl(P, K) = P11 + P12 * K * (I - P22*K)^{-1} * P21
319
+
320
+ Parameters
321
+ ----------
322
+ plant_ss_dt : scipy.signal.StateSpace (discrete, dt=T)
323
+ Lifted DT plant with block structure::
324
+
325
+ [z] [P11 P12] [w]
326
+ [y] = [P21 P22] [u]
327
+
328
+ K_ss_dt : scipy.signal.StateSpace (discrete, dt=T)
329
+ Discrete-time controller K(z).
330
+ n_meas : int
331
+ Number of measurement outputs (rows of P21/P22).
332
+ n_ctrl : int
333
+ Number of control inputs (cols of P12/P22).
334
+
335
+ Returns
336
+ -------
337
+ dcl : scipy.signal.StateSpace
338
+ Closed-loop system (w → z).
339
+ """
340
+ Ap = plant_ss_dt.A; Bp = plant_ss_dt.B
341
+ Cp = plant_ss_dt.C; Dp = plant_ss_dt.D
342
+ T = plant_ss_dt.dt
343
+
344
+ nout = Cp.shape[0]; nin = Bp.shape[1]
345
+ o1 = nout - n_meas; i1 = nin - n_ctrl
346
+
347
+ B1 = Bp[:, :i1]; B2 = Bp[:, i1:]
348
+ C1 = Cp[:o1, :]; C2 = Cp[o1:, :]
349
+ D11 = Dp[:o1, :i1]; D12 = Dp[:o1, i1:]
350
+ D21 = Dp[o1:, :i1]; D22 = Dp[o1:, i1:]
351
+
352
+ Ak = K_ss_dt.A; Bk = K_ss_dt.B
353
+ Ck = K_ss_dt.C; Dk = K_ss_dt.D
354
+
355
+ # M1 = (I - Dk @ D22)^{-1}, M2 = (I - D22 @ Dk)^{-1}
356
+ I_k = np.eye(n_ctrl)
357
+ I_m = np.eye(n_meas)
358
+ try:
359
+ M1 = np.linalg.solve(I_k - Dk @ D22, I_k)
360
+ except np.linalg.LinAlgError:
361
+ M1 = I_k # fallback when D22 = 0
362
+ try:
363
+ M2 = np.linalg.solve(I_m - D22 @ Dk, I_m)
364
+ except np.linalg.LinAlgError:
365
+ M2 = I_m
366
+
367
+ Acl = np.block([
368
+ [Ap + B2 @ Dk @ M2 @ C2, B2 @ M1 @ Ck ],
369
+ [Bk @ M2 @ C2, Ak + Bk @ M2 @ D22 @ Ck ],
370
+ ])
371
+ Bcl = np.vstack([
372
+ B1 + B2 @ Dk @ M2 @ D21,
373
+ Bk @ M2 @ D21,
374
+ ])
375
+ Ccl = np.hstack([
376
+ C1 + D12 @ Dk @ M2 @ C2,
377
+ D12 @ M1 @ Ck,
378
+ ])
379
+ Dcl = D11 + D12 @ Dk @ M2 @ D21
380
+
381
+ return sig.StateSpace(Acl, Bcl, Ccl, Dcl, dt=T)
382
+
383
+
384
+ def compute_gamma(plant_ss, T, n_meas=1, n_ctrl=1):
385
+ """
386
+ Compute the inter-sample correction term γ for H2-optimal design.
387
+
388
+ γ = (1/T) tr( B1' * ∫_0^T e^{A'τ} C1'C1 e^{Aτ} dτ * B1 )
389
+
390
+ The total H2-cost is J = √(‖P_lifted‖²_H2 + γ).
391
+
392
+ Parameters
393
+ ----------
394
+ plant_ss : scipy.signal.StateSpace
395
+ T : float
396
+ n_meas, n_ctrl : int
397
+
398
+ Returns
399
+ -------
400
+ gamma : float
401
+ """
402
+ _, gamma, _ = lift_h2(plant_ss, T, n_meas, n_ctrl)
403
+ return gamma