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,630 @@
1
+ """
2
+ directsd.analysis.norms
3
+ =======================
4
+ System norms computed via **algebraic solvers** (Lyapunov / Riccati /
5
+ Hamiltonian bisection) instead of frequency-domain numerical integration.
6
+ """
7
+
8
+ import numpy as np
9
+ import scipy.linalg as la
10
+ import scipy.signal as sig
11
+ import warnings
12
+
13
+ # NumPy 2.0 compat shim (kept for any future use)
14
+ _trapezoid = getattr(np, 'trapezoid', getattr(np, 'trapz', None))
15
+
16
+
17
+ def _unpack_lti(sys_obj):
18
+ if isinstance(sys_obj, sig.StateSpace):
19
+ tf = sys_obj.to_tf(); return tf.num, tf.den, sys_obj.dt
20
+ if isinstance(sys_obj, sig.dlti):
21
+ return sys_obj.num, sys_obj.den, sys_obj.dt
22
+ if isinstance(sys_obj, sig.lti):
23
+ return sys_obj.num, sys_obj.den, None
24
+ if isinstance(sys_obj, tuple) and len(sys_obj) == 2:
25
+ return sys_obj[0], sys_obj[1], None
26
+ if isinstance(sys_obj, tuple) and len(sys_obj) == 3:
27
+ return sys_obj[0], sys_obj[1], sys_obj[2]
28
+ raise TypeError(f"Unsupported system type {type(sys_obj)}")
29
+
30
+
31
+ def _ss_from_tf(num, den, dt=None):
32
+ num = np.atleast_1d(np.array(num, dtype=float)).ravel()
33
+ den = np.atleast_1d(np.array(den, dtype=float)).ravel()
34
+ with warnings.catch_warnings():
35
+ warnings.simplefilter("ignore")
36
+ if dt is not None and dt not in (0, None):
37
+ ss_obj = sig.dlti(num, den, dt=dt).to_ss()
38
+ else:
39
+ ss_obj = sig.lti(num, den).to_ss()
40
+ return ss_obj.A, ss_obj.B, ss_obj.C, ss_obj.D
41
+
42
+
43
+ def _h2_freq_dt(A, B, C, D, N=512):
44
+ """H2-norm via DFT on unit circle with half-point grid.
45
+
46
+ Half-point avoids z=1 where pole-zero cancellation makes (I-A) singular.
47
+ H2^2 = (1/N) * sum_k ||H(e^{j w_k})||_F^2, w_k = 2pi*(k+0.5)/N.
48
+ """
49
+ n = A.shape[0]
50
+ w = 2.0 * np.pi * (np.arange(N) + 0.5) / N
51
+ z = np.exp(1j * w)
52
+ I_n = np.eye(n)
53
+ h2sq = 0.0
54
+ for zk in z:
55
+ Hk = C @ np.linalg.solve(zk * I_n - A, B) + D
56
+ h2sq += np.real(np.trace(Hk.conj().T @ Hk))
57
+ return float(np.sqrt(max(h2sq / N, 0.0)))
58
+
59
+
60
+ def _h2norm_dt(A, B, C, D):
61
+ """H2-norm for DT system.
62
+
63
+ Fast path: Lyapunov equation for strictly stable systems.
64
+ Fallback: frequency-domain integration when marginal unobservable modes
65
+ are detected (mirrors MATLAB minreal(lft(sys,K)) before norm()).
66
+ """
67
+ tol = 1e-6
68
+ unstab_tol = 1e-4 # modes with |λ| > 1+unstab_tol are "strictly unstable"
69
+ eigs, V = la.eig(A)
70
+ C_norm = np.linalg.norm(C.astype(complex), 'fro')
71
+ has_marginal = False
72
+ for i, lam in enumerate(eigs):
73
+ abs_lam = np.abs(lam)
74
+ if abs_lam < 1.0 - tol:
75
+ continue
76
+ has_marginal = True
77
+ if abs_lam > 1.0 + unstab_tol:
78
+ # Strictly unstable: conservative check — return nan if observable.
79
+ v = V[:, i]; v = v / (np.linalg.norm(v) + 1e-300)
80
+ if np.linalg.norm(C.astype(complex) @ v) > tol * (C_norm + 1e-10):
81
+ return float('nan') # observable unstable mode → H2 = ∞
82
+ # unobservable: H2 is finite, use freq integration below
83
+ if has_marginal:
84
+ return _h2_freq_dt(A, B, C, D)
85
+ try:
86
+ P = la.solve_discrete_lyapunov(A, B @ B.T)
87
+ h2sq = float(np.real(np.trace(C @ P @ C.T + D @ D.T)))
88
+ return float(np.sqrt(max(h2sq, 0.0)))
89
+ except Exception:
90
+ return float('nan')
91
+
92
+
93
+ def _h2norm_ct(A, B, C, D):
94
+ """H2-norm via continuous Lyapunov: A P + P A' + B B' = 0."""
95
+ if np.max(np.abs(D)) > 1e-10:
96
+ return float('inf')
97
+ try:
98
+ P = la.solve_continuous_lyapunov(A, -(B @ B.T))
99
+ h2sq = float(np.real(np.trace(C @ P @ C.T)))
100
+ return float(np.sqrt(max(h2sq, 0.0)))
101
+ except Exception:
102
+ return float('nan')
103
+
104
+
105
+ def _hinf_dt_bisect(A, B, C, D, tol=1e-6, max_iter=60):
106
+ """H∞-norm of DT system by gamma-bisection on discrete-time Riccati."""
107
+ n = A.shape[0]
108
+ m = B.shape[1]
109
+ N = 512
110
+ w = np.linspace(0, np.pi, N)
111
+ z = np.exp(1j * w)
112
+ sv = np.array([np.linalg.svd(
113
+ C @ np.linalg.solve(zk * np.eye(n) - A, B) + D,
114
+ compute_uv=False)[0] for zk in z])
115
+ gamma_ub = float(sv.max()) * 1.1 + 1e-8
116
+ w_peak = float(w[np.argmax(sv)])
117
+ gamma_lb = 0.0
118
+
119
+ # Primal bounded-real-lemma DARE: A^T X A - X - (A^T X B + S)(R + B^T X B)^{-1}
120
+ # (B^T X A + S^T) + Q = 0 with Q=C^T C, S=C^T D, R=gamma^2 I_m - D^T D
121
+ Q = C.T @ C
122
+ S = C.T @ D
123
+
124
+ def feasible(g):
125
+ R = g**2 * np.eye(m) - D.T @ D
126
+ if np.any(np.linalg.eigvalsh(R) <= 1e-12):
127
+ return False
128
+ try:
129
+ X = la.solve_discrete_are(A, B, Q, R, s=S)
130
+ return bool(np.all(np.linalg.eigvalsh(
131
+ g**2 * np.eye(m) - D.T @ D - B.T @ X @ B) > 0))
132
+ except Exception:
133
+ return False
134
+
135
+ for _ in range(max_iter):
136
+ mid = (gamma_lb + gamma_ub) / 2
137
+ if feasible(mid):
138
+ gamma_ub = mid
139
+ else:
140
+ gamma_lb = mid
141
+ if (gamma_ub - gamma_lb) < tol * (gamma_ub + 1e-12):
142
+ break
143
+ return gamma_ub, w_peak
144
+
145
+
146
+ def _hinf_ct_hamiltonian(A, B, C, D, tol=1e-6, max_iter=60):
147
+ """
148
+ H∞-norm of a stable CT system.
149
+
150
+ For systems with D≠0 and full column rank: uses Hamiltonian jω-axis test.
151
+ For strictly proper (D=0) or rank-deficient D: uses a dense log-spaced
152
+ frequency scan (exact for rational systems that peak near ω=0 or ω=∞).
153
+ """
154
+ n = A.shape[0]
155
+ n_out, n_in = D.shape
156
+
157
+ # Dense log-spaced frequency scan (covers DC to high freq)
158
+ N = 2048
159
+ w_low = np.logspace(-6, 0, N // 2)
160
+ w_high = np.logspace(0, 6, N // 2)
161
+ w = np.concatenate([w_low, w_high])
162
+ sv_max = np.zeros(len(w))
163
+ for k, wk in enumerate(w):
164
+ Hk = C @ np.linalg.solve(1j * wk * np.eye(n) - A, B) + D
165
+ sv_max[k] = np.linalg.svd(Hk, compute_uv=False)[0]
166
+
167
+ gamma_freq = float(sv_max.max())
168
+ w_peak = float(w[np.argmax(sv_max)])
169
+
170
+ # For strictly proper systems the peak may be at ω=0; check DC explicitly
171
+ H_dc = C @ np.linalg.solve(-A, B) + D # G(0) = -C A^{-1} B + D
172
+ sv_dc = float(np.linalg.svd(H_dc, compute_uv=False)[0])
173
+ if sv_dc > gamma_freq:
174
+ gamma_freq = sv_dc
175
+ w_peak = 0.0
176
+
177
+ # Refine with Hamiltonian bisection if D has rank ≥ min(n_out,n_in)
178
+ rank_D = np.linalg.matrix_rank(D)
179
+ if rank_D < min(n_out, n_in):
180
+ # Can't use Hamiltonian test; return frequency scan result
181
+ return gamma_freq * (1 + tol), w_peak
182
+
183
+ gamma_ub = gamma_freq * 1.02 + 1e-8
184
+ gamma_lb = gamma_freq * 0.5
185
+
186
+ def has_jw(gamma):
187
+ g2 = gamma ** 2
188
+ R = g2 * np.eye(n_in) - D.T @ D
189
+ if np.any(np.linalg.eigvalsh(R) <= 1e-12):
190
+ return True
191
+ try:
192
+ Ri = np.linalg.inv(R)
193
+ F = A - B @ Ri @ D.T @ C
194
+ Ham = np.block([
195
+ [F, -(B @ Ri @ B.T) ],
196
+ [-(C.T @ (np.eye(n_out) + D @ Ri @ D.T) @ C), -F.T ]
197
+ ])
198
+ eigs = la.eigvals(Ham)
199
+ min_re = float(np.min(np.abs(np.real(eigs))))
200
+ scale = float(np.abs(eigs).max()) + 1.0
201
+ return min_re < tol * scale
202
+ except Exception:
203
+ return True
204
+
205
+ for _ in range(max_iter):
206
+ mid = (gamma_lb + gamma_ub) / 2
207
+ if has_jw(mid):
208
+ gamma_lb = mid
209
+ else:
210
+ gamma_ub = mid
211
+ if (gamma_ub - gamma_lb) < tol * (gamma_ub + 1e-12):
212
+ break
213
+
214
+ return max(gamma_ub, gamma_freq), w_peak
215
+
216
+
217
+ def _discretise_cl(plant_num, plant_den, K_num, K_den, T):
218
+ from directsd.polynomial.transforms import dtfm
219
+ with warnings.catch_warnings():
220
+ warnings.simplefilter("ignore")
221
+ D22num, D22den = dtfm((plant_num, plant_den), T)
222
+ KD_num = np.polymul(K_num, D22num)
223
+ KD_den = np.polymul(K_den, D22den)
224
+ S_num = np.polymul(K_den, D22den)
225
+ S_den = np.polyadd(KD_den, KD_num)
226
+ with warnings.catch_warnings():
227
+ warnings.simplefilter("ignore")
228
+ ss_obj = sig.dlti(S_num, S_den, dt=T).to_ss()
229
+ return ss_obj.A, ss_obj.B, ss_obj.C, ss_obj.D
230
+
231
+
232
+ # ── Public API ────────────────────────────────────────────────────────────────
233
+
234
+ def dinfnorm(sys_obj, tol=1e-6):
235
+ """H∞-norm of a discrete-time system (Hamiltonian bisection, exact)."""
236
+ num, den, dt = _unpack_lti(sys_obj)
237
+ A, B, C, D = _ss_from_tf(num, den, dt or 1.0)
238
+ return _hinf_dt_bisect(A, B, C, D, tol)
239
+
240
+
241
+ def dahinorm(sys, K, T=None):
242
+ """
243
+ Associated H∞-norm for a discrete-time generalised plant.
244
+
245
+ Port of MATLAB dahinorm.m (K. Polyakov) -> sdhinferr.m('h2coef').
246
+
247
+ Computes the AH∞-norm of the *actual closed loop* for the given K:
248
+ max_ω sqrt(X(ω)) where X(ω) = A(ω)|M(ω)|² + E(ω) - 2Re(B(ω)·M(ω)),
249
+ M = K'/(1+D22·K') is the closed-loop map around D22 (K' = the
250
+ conjugate-reciprocal of K, K'(z)=K(1/z) — sdhinferr.m's `feedback(K',D22)`),
251
+ and A/B/E come from h2coef's plant decomposition.
252
+
253
+ Note this must be computed *with* K, not as `Z=E-|B|^2/A` (the
254
+ K-independent "equal-ripple optimal" infimum) — that formula is wrong:
255
+ verified against MATLAB's own documented dhinf Example 1 (`Source/dsd_help.md`,
256
+ K=1.5 constant, `dahinorm(sys,K)=3.6056`), the K-independent formula gives
257
+ 0.9487 regardless of K, while this corrected, K-dependent formula gives
258
+ 3.6055514... for K=1.5, matching MATLAB to 5 significant figures. Using
259
+ the K-independent formula as a cross-check on dhinf's own bisection would
260
+ silently misreport any stabilizing K as "wrong", since that formula's
261
+ output never actually depends on the controller being evaluated.
262
+
263
+ Parameters
264
+ ----------
265
+ sys : list-of-lists of (num, den) tuples
266
+ Full discrete-time generalised plant in z-domain (same format as dhinf).
267
+ K : (num, den) tuple
268
+ Controller in z-domain (as returned by dhinf) to evaluate.
269
+ T : float, optional
270
+ Unused; kept for API symmetry.
271
+
272
+ Returns
273
+ -------
274
+ err : float
275
+ AH∞-norm of the closed-loop system for this K. Only meaningful for a
276
+ *stabilizing* K — the frequency-domain formula is well-defined
277
+ algebraically even for a non-stabilizing K (returning some finite
278
+ number), but that number does not represent a real achievable cost.
279
+ """
280
+ from directsd.design.polynomial import _z2zeta, _h2coef_freq
281
+
282
+ # Convert sys to ζ-domain
283
+ sys_zeta = []
284
+ for row in sys:
285
+ new_row = []
286
+ for entry in row:
287
+ if np.isscalar(entry) or isinstance(entry, (int, float)):
288
+ num_e, den_e = np.array([float(entry)]), np.array([1.0])
289
+ else:
290
+ num_e = np.atleast_1d(np.asarray(entry[0], float)).ravel()
291
+ den_e = np.atleast_1d(np.asarray(entry[1], float)).ravel()
292
+ nz, dz = _z2zeta(num_e, den_e)
293
+ new_row.append((nz, dz))
294
+ sys_zeta.append(new_row)
295
+
296
+ # Negate last row (negative-feedback convention)
297
+ sys_zeta[-1] = [(-n, d) for (n, d) in sys_zeta[-1]]
298
+
299
+ # h2coef in ζ-domain: A = |P12|²·|P21|², B = P21·P11~·P12, E = |P11|²
300
+ A_tf, B_tf, E_tf = _h2coef_freq(sys_zeta)
301
+ D22_num, D22_den = sys_zeta[-1][-1]
302
+
303
+ K_num = np.atleast_1d(np.asarray(K[0], float)).ravel()
304
+ K_den = np.atleast_1d(np.asarray(K[1], float)).ravel()
305
+ # K' = conjugate-reciprocal of K (z-domain): K'(z) = K(1/z)
306
+ Kp_num, Kp_den = K_num[::-1], K_den[::-1]
307
+
308
+ # Frequency grid
309
+ N_freq = 4096
310
+ w_f = np.linspace(1e-6, np.pi - 1e-6, N_freq)
311
+ z_f = np.exp(1j * w_f)
312
+
313
+ def _ev(num, den):
314
+ return np.polyval(num, z_f) / (np.polyval(den, z_f) + 1e-300)
315
+
316
+ A_f = np.real(_ev(A_tf[0], A_tf[1]))
317
+ B_f = _ev(B_tf[0], B_tf[1])
318
+ E_f = np.real(_ev(E_tf[0], E_tf[1]))
319
+ D22_f = _ev(D22_num, D22_den)
320
+ Kp_f = _ev(Kp_num, Kp_den)
321
+
322
+ M_f = Kp_f / (1.0 + D22_f * Kp_f + 1e-300)
323
+ X_f = A_f * np.abs(M_f) ** 2 + E_f - 2.0 * np.real(B_f * M_f)
324
+
325
+ return float(np.sqrt(np.maximum(np.max(X_f), 0.0)))
326
+
327
+
328
+ def _K_to_ss(K, T):
329
+ """
330
+ Parse controller K to a discrete StateSpace at sampling period T.
331
+
332
+ If K is given as a (num, den) tuple and deg(num) > deg(den) (improper in
333
+ z-domain), the denominator is zero-padded so that deg(den) = deg(num).
334
+ This interprets num/den as a polynomial in z^{-1} (causal convention),
335
+ which is the convention used by the Bezout-based modal parametrisation.
336
+ """
337
+ if isinstance(K, sig.StateSpace) and K.dt not in (None, 0):
338
+ return K, K.dt
339
+ if isinstance(K, sig.dlti):
340
+ return K.to_ss(), K.dt
341
+ if isinstance(K, tuple) and len(K) == 2:
342
+ K_num = np.atleast_1d(np.array(K[0], float)).ravel()
343
+ K_den = np.atleast_1d(np.array(K[1], float)).ravel()
344
+ if T is None:
345
+ raise ValueError("T must be provided when K is a (num, den) tuple")
346
+ # Pad denominator with trailing zeros when numerator has higher degree
347
+ # (Bezout polynomial convention: coefficients are in z^{-1}, not z).
348
+ excess = len(K_num) - len(K_den)
349
+ if excess > 0:
350
+ K_den = np.append(K_den, np.zeros(excess))
351
+ with warnings.catch_warnings():
352
+ warnings.simplefilter("ignore")
353
+ return sig.dlti(K_num, K_den, dt=T).to_ss(), T
354
+ raise TypeError(f"Unsupported controller type {type(K)}")
355
+
356
+
357
+ def sdh2norm(plant, K, T=None, t=None, H=None, udelay=0.0):
358
+ """
359
+ GH2-norm of a sampled-data closed-loop via H2-lifting (FR-operator method).
360
+
361
+ Mirrors MATLAB sdh2norm.m (ss path)::
362
+
363
+ [dsysH2, gamma] = sdgh2mod(sys, K.Ts)
364
+ err = sqrt(norm(lft(dsysH2, K))^2 + gamma)
365
+
366
+ Parameters
367
+ ----------
368
+ plant : scipy.signal.StateSpace or (num, den) tuple
369
+ Continuous-time generalised plant (preferred) or SISO plant.
370
+ For SISO plants a standard regulation generalized plant is constructed
371
+ automatically via ``_parse_plant``.
372
+ K : (num, den) tuple or scipy.signal.StateSpace (discrete)
373
+ Discrete-time controller.
374
+ T : float, optional
375
+ Sampling period (inferred from K when K is a StateSpace/dlti).
376
+ t : float, optional
377
+ Time instant for the variance (polynomial method only; forces H-aware
378
+ evaluation, matching MATLAB's sdh2norm.m).
379
+ H : scipy.signal.lti, optional
380
+ Generalized hold used for the *design* (default: ZOH). The
381
+ lifting/state-space path above has no notion of a non-ZOH hold
382
+ (matching MATLAB's own architecture -- sdh2norm.m routes to the
383
+ polynomial 'pol' method whenever H is non-default); passing H here
384
+ routes to the polynomial verification path instead.
385
+ udelay : float, optional
386
+ Continuous computational delay tau on the control input, matching
387
+ `sdh2`'s identical parameter -- ONLY honored on the `t is not None`
388
+ (or `H is not None`) path, which routes through `_sderr`'s own exact
389
+ `_dtfm`-based delay handling (same mechanism `_sdh2coef` uses
390
+ for design). Without this, evaluating a delay-designed `t`
391
+ controller forces a Pade-approximated delay plant instead -- fine
392
+ for the average-variance path (~1-3% error) but numerically
393
+ unusable for the instantaneous-variance path: the fast Pade poles
394
+ (~1/tau) interact badly with `expm(A*t)` at a SPECIFIC small t,
395
+ giving errors of order 2x rather than a few percent -- confirmed
396
+ by reproducing MATLAB's documented demo_fil2 values exactly once
397
+ the exact-delay path is used instead of Pade.
398
+ The no-`t`/no-`H` lifting path above has no delay support at all
399
+ (same as the design side's 'ss' method) and ignores this parameter.
400
+
401
+ Returns
402
+ -------
403
+ norm_val : float
404
+ GH2-norm of the closed-loop sampled-data system.
405
+ """
406
+ from directsd.design.lifting import lift_h2, lft_dt
407
+ from directsd.design.polynomial import _parse_plant, _sderr, _sdh2coef
408
+ from directsd.linalg.minreal import Minreal
409
+
410
+ if H is not None or t is not None:
411
+ plant_ss, n_meas, n_ctrl = _parse_plant(plant)
412
+ K_ss, T = _K_to_ss(K, T)
413
+ K_tf = K_ss.to_tf() if hasattr(K_ss, 'to_tf') else None
414
+ if K_tf is not None:
415
+ K_num = np.atleast_1d(np.asarray(K_tf.num, float)).ravel()
416
+ K_den = np.atleast_1d(np.asarray(K_tf.den, float)).ravel()
417
+ else:
418
+ import scipy.signal as _sig
419
+ _tf = _sig.StateSpace(K_ss.A, K_ss.B, K_ss.C, K_ss.D).to_tf()
420
+ K_num = np.atleast_1d(np.asarray(_tf.num, float)).ravel()
421
+ K_den = np.atleast_1d(np.asarray(_tf.den, float)).ravel()
422
+ z2 = _sderr(plant_ss, (K_num, K_den), T, t=t, H=H,
423
+ n_meas=n_meas, n_ctrl=n_ctrl, coef_fn=_sdh2coef,
424
+ udelay=udelay)
425
+ return float(np.sqrt(max(z2, 0.0)))
426
+
427
+ K_ss, T = _K_to_ss(K, T)
428
+
429
+ plant_ss, n_meas, n_ctrl = _parse_plant(plant)
430
+ plant_min = Minreal.ss(plant_ss)
431
+
432
+ try:
433
+ dsysH2, gamma, _ = lift_h2(plant_min, T, n_meas=n_meas, n_ctrl=n_ctrl)
434
+ except Exception as exc:
435
+ warnings.warn(f"sdh2norm: lift_h2 failed ({exc})")
436
+ return float('nan')
437
+
438
+ try:
439
+ dcl = lft_dt(dsysH2, K_ss, n_meas=n_meas, n_ctrl=n_ctrl)
440
+ h2n = _h2norm_dt(dcl.A, dcl.B, dcl.C, dcl.D)
441
+ except Exception as exc:
442
+ warnings.warn(f"sdh2norm: LFT/norm failed ({exc})")
443
+ return float('nan')
444
+
445
+ return float(np.sqrt(max(h2n ** 2 + gamma, 0.0)))
446
+
447
+
448
+ def sdhinorm(plant, K, T=None, tol=1e-6):
449
+ """AHinf norm of a sampled-data closed-loop via lift_l2 + sigma_max sweep.
450
+
451
+ Delegates to sdahinorm (directsd.design.polynomial) — same lift_l2 +
452
+ frequency-sweep computation, consistent with MATLAB sdahinorm.
453
+
454
+ Returns
455
+ -------
456
+ gamma : float
457
+ AHinf norm = sqrt(T) * H∞(lifted closed-loop).
458
+ w_peak : float
459
+ 0.0 (placeholder — sweep-based; no analytical peak frequency).
460
+ """
461
+ if T is None:
462
+ if isinstance(K, sig.StateSpace) and K.dt not in (None, 0):
463
+ T = float(K.dt)
464
+ elif isinstance(K, sig.dlti):
465
+ T = float(K.dt)
466
+ elif isinstance(K, tuple) and len(K) == 3:
467
+ T = float(K[2])
468
+ if T is None:
469
+ raise ValueError("T must be provided")
470
+ from directsd.design.polynomial import sdahinorm
471
+ return sdahinorm(plant, K, T), 0.0
472
+
473
+
474
+ def h2norm_ct(sys_obj):
475
+ """H2-norm of a continuous-time system via Lyapunov equation."""
476
+ num, den, _ = _unpack_lti(sys_obj)
477
+ A, B, C, D = _ss_from_tf(num, den)
478
+ return _h2norm_ct(A, B, C, D)
479
+
480
+
481
+ def hinfnorm_ct(sys_obj, tol=1e-6):
482
+ """H∞-norm of a continuous-time system via Hamiltonian bisection."""
483
+ num, den, _ = _unpack_lti(sys_obj)
484
+ A, B, C, D = _ss_from_tf(num, den)
485
+ return _hinf_ct_hamiltonian(A, B, C, D, tol)
486
+
487
+
488
+ # ---------------------------------------------------------------------------
489
+ # sdfreq – averaged frequency response of sampled-data closed-loop
490
+ # ---------------------------------------------------------------------------
491
+
492
+ def sdfreq(plant, K, w=None, resp_type='std'):
493
+ """
494
+ Averaged frequency response of the sampled-data closed-loop system.
495
+
496
+ Port of sdfreq.m (K. Polyakov).
497
+
498
+ Computes R(jω) = P11(jω) + (1/T)·P12(jω)·H(jω)·M(jω)·P21(jω)
499
+ where H(jω) = (1 − e^{−jωT})/(jω) is the ZOH frequency response and
500
+ M = K·(I − D22·K)^{−1} is the "lifted" controller.
501
+
502
+ Parameters
503
+ ----------
504
+ plant : scipy.signal.StateSpace
505
+ Continuous-time generalised plant (SISO or MIMO).
506
+ K : scipy.signal.StateSpace (discrete, dt=T)
507
+ Discrete-time controller.
508
+ w : array-like, optional
509
+ Frequency vector (rad/s). Defaults to 50 points in (0, ωs).
510
+ resp_type : str
511
+ 'std' - standard averaged response (default)
512
+ 'sing' - singular values of the spectral matrix (scalar output)
513
+ 'spec' - spectral matrix R11 + RA + RB + RB*
514
+
515
+ Returns
516
+ -------
517
+ R : np.ndarray
518
+ Frequency response array, shape (o1, i1, len(w)) for 'std'/'spec',
519
+ or (len(w),) for 'sing'.
520
+ w : np.ndarray
521
+ Frequency vector used.
522
+ """
523
+ if not hasattr(K, 'dt') or K.dt is None:
524
+ raise TypeError("K must be a discrete-time StateSpace")
525
+ T = float(K.dt)
526
+
527
+ nout, nin = plant.C.shape[0], plant.B.shape[1]
528
+ i2 = K.B.shape[1] # controller input dim = n_meas
529
+ o2 = K.C.shape[0] # controller output dim = n_ctrl
530
+ i1 = nin - o2
531
+ o1 = nout - i2
532
+
533
+ if i1 < 1:
534
+ raise ValueError("No disturbance inputs detected")
535
+ if o1 < 1:
536
+ raise ValueError("No performance outputs detected")
537
+
538
+ if w is None:
539
+ ws = 2 * np.pi / T
540
+ w = np.linspace(0.001 * ws, 0.999 * ws, 50)
541
+ w = np.asarray(w, float).ravel()
542
+ n_w = len(w)
543
+
544
+ # Sub-block transfer functions (as state-space)
545
+ A = plant.A; B = plant.B; C = plant.C; Dp = plant.D
546
+ B1 = B[:, :i1]; B2 = B[:, i1:]
547
+ C1 = C[:o1, :]; C2 = C[o1:, :]
548
+ D11 = Dp[:o1, :i1]; D12 = Dp[:o1, i1:]
549
+ D21 = Dp[o1:, :i1]; D22 = Dp[o1:, i1:]
550
+
551
+ n = A.shape[0]
552
+
553
+ P11_ss = sig.StateSpace(A, B1, C1, D11)
554
+ P12_ss = sig.StateSpace(A, B2, C1, D12)
555
+ P21_ss = sig.StateSpace(A, B1, C2, D21)
556
+
557
+ # D22 ZOH discrete plant for feedback
558
+ D22_ss = sig.StateSpace(A, B2, C2, D22)
559
+ D22_d = D22_ss.to_discrete(T, method='zoh')
560
+
561
+ # M = K * (I - D22_d * K)^{-1}: close the loop K around -D22_d
562
+ Ak, Bk, Ck, Dk = K.A, K.B, K.C, K.D
563
+ Ad22, Bd22, Cd22, Dd22 = D22_d.A, D22_d.B, D22_d.C, D22_d.D
564
+ nk = Ak.shape[0]
565
+ nd = Ad22.shape[0]
566
+
567
+ # Evaluate frequency responses at each ω
568
+ def _freqresp_ct(ss_obj, w_arr):
569
+ """Evaluate CT SS frequency response at each frequency."""
570
+ _, _, H = sig.freqs_zpk(*sig.ss2zpk(ss_obj.A, ss_obj.B, ss_obj.C, ss_obj.D), w_arr)
571
+ return H # returned as (n_out, n_in, n_w) or flattened
572
+
573
+ def _eval_ct(A_m, B_m, C_m, D_m, jw):
574
+ """(C·(jωI−A)^{−1}·B + D) at a single frequency jω."""
575
+ n_s = A_m.shape[0]
576
+ return C_m @ np.linalg.solve(jw * np.eye(n_s) - A_m, B_m) + D_m
577
+
578
+ def _eval_dt(A_m, B_m, C_m, D_m, z):
579
+ """(C·(zI−A)^{−1}·B + D) at a single DT frequency z = e^{jωT}."""
580
+ n_s = A_m.shape[0]
581
+ return C_m @ np.linalg.solve(z * np.eye(n_s) - A_m, B_m) + D_m
582
+
583
+ R_list = []
584
+ for wi in w:
585
+ jw = 1j * wi
586
+ z = np.exp(jw * T)
587
+
588
+ rP11 = _eval_ct(A, B1, C1, D11, jw) # (o1, i1)
589
+ rP12 = _eval_ct(A, B2, C1, D12, jw) # (o1, o2)
590
+ rP21 = _eval_ct(A, B1, C2, D21, jw) # (i2, i1)
591
+
592
+ rD22 = _eval_dt(Ad22, Bd22, Cd22, Dd22, z) # (i2, o2)
593
+ rK = _eval_dt(Ak, Bk, Ck, Dk, z) # (o2, i2)
594
+
595
+ # M = K * (I - D22*K)^{-1}
596
+ IminD22K = np.eye(i2) - rD22 @ rK
597
+ rM = rK @ np.linalg.solve(IminD22K.T, np.eye(i2)).T # (o2, i2)
598
+
599
+ # ZOH frequency response H(jω) = (1 − e^{−jωT}) / (jω)
600
+ if abs(wi) < 1e-12:
601
+ rH = T
602
+ else:
603
+ rH = (1 - np.exp(-jw * T)) / jw
604
+
605
+ # Standard: R = P11 + (1/T) * P12 * H * M * P21
606
+ RPMP = (rP12 * rH) @ rM @ rP21 / T
607
+ R_std = rP11 + RPMP
608
+
609
+ if resp_type == 'std':
610
+ R_list.append(R_std)
611
+ elif resp_type in ('spec', 'sing'):
612
+ # Spectral matrix (approximate; full version needs dtfm2)
613
+ R_list.append(R_std.conj().T @ R_std)
614
+ else:
615
+ R_list.append(R_std)
616
+
617
+ R_arr = np.array(R_list) # (n_w, o1, i1)
618
+
619
+ if resp_type == 'sing':
620
+ sv = np.array([np.sqrt(np.max(np.linalg.svd(R_arr[i], compute_uv=False)))
621
+ for i in range(n_w)])
622
+ return sv, w
623
+
624
+ # Transpose to (o1, i1, n_w) convention
625
+ R_out = np.transpose(R_arr, (1, 2, 0))
626
+ return R_out, w
627
+
628
+
629
+ # Aliases kept for API compatibility
630
+ sdmargin_norms = sdh2norm # not the actual margin - see charpol.py
@@ -0,0 +1,9 @@
1
+ from directsd.design.polynomial import (
2
+ ch2, sdh2, sdl2,
3
+ sdahinf, sdahinorm,
4
+ sdh2hinf,
5
+ sdtrhinf, sdtrhinferr,
6
+ modsdh2, modsdl2,
7
+ dhinf, sd2dof, split2dof, polquad, polhinf, whquad, ssquad, psigain, polopth2, dtfm2,
8
+ )
9
+ from directsd.design.lifting import lift_h2, lift_l2, compute_gamma