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,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
|