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,425 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Minimal-realization (pole/zero and state cancellation) utilities for DirectSD.
|
|
3
|
+
|
|
4
|
+
Port of MATLAB's ``@zpk/minreal.m`` and ``@zpk/minreals.m`` — both revised by
|
|
5
|
+
K. Polyakov from the original MathWorks Control System Toolbox ``minreal``.
|
|
6
|
+
MATLAB exposes these as ``minreal(sys, tol)`` / ``minreals(sys, tol)``, whose
|
|
7
|
+
behavior dispatches on the class of ``sys`` for the former (Kalman
|
|
8
|
+
decomposition for state-space models, root cancellation for zpk/transfer-
|
|
9
|
+
function models). Python has no such class-based dispatch for plain ndarrays
|
|
10
|
+
and scipy objects, so :class:`Minreal` groups all three operations under one
|
|
11
|
+
namespace instead of leaving them as differently-named free functions:
|
|
12
|
+
|
|
13
|
+
Minreal.ss(...) — state-space (Kalman decomposition)
|
|
14
|
+
Minreal.tf(...) — zpk/tf, general (conjugate-pair symmetry)
|
|
15
|
+
Minreal.tf_symmetric(...) — zpk/tf, symmetric spectral density
|
|
16
|
+
(reciprocal-pair symmetry; port of minreals.m)
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
import numpy as np
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class Minreal:
|
|
23
|
+
"""Namespace for DirectSD's minimal-realization operations."""
|
|
24
|
+
|
|
25
|
+
@staticmethod
|
|
26
|
+
def _ctrl_staircase(A, B_ctrl, B_full, C_full, tol):
|
|
27
|
+
"""
|
|
28
|
+
Reduce to the controllable part from B_ctrl using the Rosenbrock staircase.
|
|
29
|
+
|
|
30
|
+
First step uses B_ctrl to find directly-reachable states; subsequent steps
|
|
31
|
+
use the coupling block A21 (lower-left of partitioned A) to find additionally
|
|
32
|
+
reachable states via the system dynamics.
|
|
33
|
+
|
|
34
|
+
B_full and C_full are transformed consistently.
|
|
35
|
+
|
|
36
|
+
Returns (A_new, B_ctrl_new, B_full_new, C_full_new, nc).
|
|
37
|
+
"""
|
|
38
|
+
import scipy.linalg as la
|
|
39
|
+
n, m = A.shape[0], B_ctrl.shape[1]
|
|
40
|
+
if n == 0:
|
|
41
|
+
return A, B_ctrl, B_full, C_full, 0
|
|
42
|
+
|
|
43
|
+
# Absolute threshold: based on scale of original B and A to avoid dividing by ~0
|
|
44
|
+
sys_scale = max(np.linalg.norm(B_ctrl, 'fro'), np.linalg.norm(A, 'fro'), 1e-300)
|
|
45
|
+
abs_tol = tol * sys_scale
|
|
46
|
+
|
|
47
|
+
nc = 0
|
|
48
|
+
nc_prev = 0
|
|
49
|
+
Aw = A.copy()
|
|
50
|
+
Bctrl = B_ctrl.copy()
|
|
51
|
+
Bfull = B_full.copy()
|
|
52
|
+
Cfull = C_full.copy()
|
|
53
|
+
|
|
54
|
+
for step in range(n):
|
|
55
|
+
if step == 0:
|
|
56
|
+
Msub = Bctrl # first step: use B directly
|
|
57
|
+
else:
|
|
58
|
+
Msub = Aw[nc:, nc_prev:nc] # subsequent steps: coupling block A21
|
|
59
|
+
|
|
60
|
+
nr = Msub.shape[0]
|
|
61
|
+
mc = Msub.shape[1]
|
|
62
|
+
if nr == 0 or mc == 0:
|
|
63
|
+
break
|
|
64
|
+
|
|
65
|
+
# SVD guarantees singular values are sorted descending, so the first rk
|
|
66
|
+
# left singular vectors always span the full column space of Msub —
|
|
67
|
+
# no zero can appear between two nonzero values as happens with non-pivoting QR.
|
|
68
|
+
U2, sv, _ = la.svd(Msub, full_matrices=True) # U2: (nr × nr) orthogonal
|
|
69
|
+
rk = int(np.sum(sv > abs_tol))
|
|
70
|
+
# Sign-normalise for platform determinism: make largest-magnitude element positive.
|
|
71
|
+
for k in range(rk):
|
|
72
|
+
idx = np.argmax(np.abs(U2[:, k]))
|
|
73
|
+
if U2[idx, k] < 0:
|
|
74
|
+
U2[:, k] = -U2[:, k]
|
|
75
|
+
Q2 = U2
|
|
76
|
+
if rk == 0:
|
|
77
|
+
break
|
|
78
|
+
|
|
79
|
+
Qfull = np.eye(n)
|
|
80
|
+
Qfull[nc:, nc:] = Q2.T
|
|
81
|
+
Aw = Qfull @ Aw @ Qfull.T
|
|
82
|
+
Bctrl = Qfull @ Bctrl
|
|
83
|
+
Bfull = Qfull @ Bfull
|
|
84
|
+
Cfull = Cfull @ Qfull.T
|
|
85
|
+
|
|
86
|
+
nc_prev = nc
|
|
87
|
+
nc = nc + rk
|
|
88
|
+
if nc >= n:
|
|
89
|
+
break
|
|
90
|
+
|
|
91
|
+
return Aw, Bctrl, Bfull, Cfull, nc
|
|
92
|
+
|
|
93
|
+
@staticmethod
|
|
94
|
+
def ss(A, B=None, C=None, D=None, tol=None):
|
|
95
|
+
"""
|
|
96
|
+
Minimal realization of a state-space system via Rosenbrock-staircase Kalman
|
|
97
|
+
decomposition (controllability then observability, each via a sequence of
|
|
98
|
+
SVDs on successive coupling blocks rather than one large Krylov matrix).
|
|
99
|
+
|
|
100
|
+
Dispatches on its own input instead of requiring a differently-named
|
|
101
|
+
function per calling convention:
|
|
102
|
+
|
|
103
|
+
Minreal.ss(A, B, C, D) -> (A_min, B_min, C_min, D) raw ndarrays
|
|
104
|
+
Minreal.ss(sys_ss) -> StateSpace scipy object
|
|
105
|
+
|
|
106
|
+
Detected by whether `B` is given: a bare `StateSpace`-like object (anything
|
|
107
|
+
exposing `.A/.B/.C/.D`) is passed as the sole positional argument, and the
|
|
108
|
+
reduced result comes back in the same form it went in.
|
|
109
|
+
|
|
110
|
+
The staircase algorithm (`_ctrl_staircase`) is preferred over a one-shot
|
|
111
|
+
Krylov-chain SVD (`[B, AB, A^2 B, ...]` built as one matrix and SVD'd once):
|
|
112
|
+
matrix powers amplify ill-conditioning as `A` develops close/repeated
|
|
113
|
+
eigenvalues, where the staircase's one-layer-at-a-time reduction stays
|
|
114
|
+
well-conditioned.
|
|
115
|
+
|
|
116
|
+
Port of MATLAB regular.m's very first step (``sys = minreal(ss(sys),...)``),
|
|
117
|
+
which the Python `h2reg`/`hinfreg` were missing entirely. Removes
|
|
118
|
+
uncontrollable and unobservable state directions before any Riccati-based
|
|
119
|
+
synthesis is attempted.
|
|
120
|
+
|
|
121
|
+
Uses controllability/observability *matrix rank* (not the Gramian/Lyapunov
|
|
122
|
+
approach `scipy` favors elsewhere) specifically because it works when `A`
|
|
123
|
+
has eigenvalues on the imaginary axis / at z=1 (marginally stable modes,
|
|
124
|
+
e.g. plant integrators) — Lyapunov-equation-based Gramians require `A` to
|
|
125
|
+
be Hurwitz/Schur and would raise or return garbage there.
|
|
126
|
+
"""
|
|
127
|
+
as_ss = B is None
|
|
128
|
+
if as_ss:
|
|
129
|
+
sys_ss = A
|
|
130
|
+
A, B, C, D = sys_ss.A, sys_ss.B, sys_ss.C, sys_ss.D
|
|
131
|
+
|
|
132
|
+
n = A.shape[0]
|
|
133
|
+
if n == 0:
|
|
134
|
+
return sys_ss if as_ss else (A, B, C, D)
|
|
135
|
+
if tol is None:
|
|
136
|
+
tol = n * np.finfo(float).eps * max(np.linalg.norm(A), 1.0)
|
|
137
|
+
|
|
138
|
+
m, p = B.shape[1], C.shape[0]
|
|
139
|
+
|
|
140
|
+
def _empty():
|
|
141
|
+
return np.zeros((0, 0)), np.zeros((0, m)), np.zeros((p, 0)), D
|
|
142
|
+
|
|
143
|
+
# Step 1: controllability from B. B_full = C.T (kept for C recovery through
|
|
144
|
+
# the same orthogonal transform); C_full = empty (nothing else to carry).
|
|
145
|
+
Aw, Bw, Cw_t, _, nc = Minreal._ctrl_staircase(A, B, C.T, np.zeros((0, n)), tol)
|
|
146
|
+
if nc == 0:
|
|
147
|
+
A_min, B_min, C_min, D = _empty()
|
|
148
|
+
else:
|
|
149
|
+
if nc < n:
|
|
150
|
+
Aw = Aw[:nc, :nc]
|
|
151
|
+
Bw = Bw[:nc, :]
|
|
152
|
+
Cw_t = Cw_t[:nc, :]
|
|
153
|
+
|
|
154
|
+
# Step 2: observability from C (dual controllability on A.T).
|
|
155
|
+
# B_full dummy (nc×0); C_full = Bw.T (m×nc, nc cols matching A.T's dim).
|
|
156
|
+
Ao_t, Co_t_red, _, Bw_t_cfull, no = Minreal._ctrl_staircase(
|
|
157
|
+
Aw.T, Cw_t, np.zeros((nc, 0)), Bw.T, tol
|
|
158
|
+
)
|
|
159
|
+
if no == 0:
|
|
160
|
+
A_min, B_min, C_min, D = _empty()
|
|
161
|
+
else:
|
|
162
|
+
if no < nc:
|
|
163
|
+
Ao_t = Ao_t[:no, :no]
|
|
164
|
+
Co_t_red = Co_t_red[:no, :]
|
|
165
|
+
Bw_t_cfull = Bw_t_cfull[:, :no]
|
|
166
|
+
A_min, B_min, C_min = Ao_t.T, Bw_t_cfull.T, Co_t_red.T
|
|
167
|
+
|
|
168
|
+
if as_ss:
|
|
169
|
+
import scipy.signal as sig
|
|
170
|
+
return sig.StateSpace(A_min, B_min, C_min, D)
|
|
171
|
+
return A_min, B_min, C_min, D
|
|
172
|
+
|
|
173
|
+
@staticmethod
|
|
174
|
+
def _reducezp(z, p, tol, im_tol=1e-9):
|
|
175
|
+
"""
|
|
176
|
+
Cancel matching pairs of zeros (z) and poles (p) within relative tolerance
|
|
177
|
+
`tol`, preserving complex-conjugate symmetry (assumes real coefficients —
|
|
178
|
+
i.e. every non-real root has a conjugate partner in the same array).
|
|
179
|
+
|
|
180
|
+
Port of MATLAB `@zpk/minreal.m`'s private `REDUCEZP`. Naive nearest-root
|
|
181
|
+
matching deletes whichever pair it matches first — but when a
|
|
182
|
+
complex-conjugate *pair* of zeros can only be matched against a single
|
|
183
|
+
real pole (or vice versa), blindly deleting one side breaks the
|
|
184
|
+
real-coefficient symmetry of the result. MATLAB's approach instead
|
|
185
|
+
*redistributes* the residual mismatch into a nearby surviving root —
|
|
186
|
+
replacing it with a weighted average `(matched + 2*target)/3` — and
|
|
187
|
+
re-queues the leftover half for another cancellation attempt, rather
|
|
188
|
+
than just discarding it.
|
|
189
|
+
|
|
190
|
+
Returns
|
|
191
|
+
-------
|
|
192
|
+
zr, pr : ndarray (complex)
|
|
193
|
+
Surviving zeros and poles after cancellation.
|
|
194
|
+
"""
|
|
195
|
+
z = np.asarray(z, dtype=complex)
|
|
196
|
+
pr = list(np.asarray(p, dtype=complex))
|
|
197
|
+
|
|
198
|
+
def _closest(zm, lst):
|
|
199
|
+
if not lst:
|
|
200
|
+
return None, None
|
|
201
|
+
d = [abs(zm - s) for s in lst]
|
|
202
|
+
imin = int(np.argmin(d))
|
|
203
|
+
return d[imin], imin
|
|
204
|
+
|
|
205
|
+
def _pop_closest_to(target, lst):
|
|
206
|
+
d = [abs(target - s) for s in lst]
|
|
207
|
+
imin = int(np.argmin(d))
|
|
208
|
+
return lst.pop(imin)
|
|
209
|
+
|
|
210
|
+
# Represent each conjugate pair by its Im>0 member; the Im<0 partner is
|
|
211
|
+
# implicit (reconstructed at the end) and never separately tracked here —
|
|
212
|
+
# matching MATLAB's `z(imag(z)>0,:)` / `z(imag(z)==0,:)` split.
|
|
213
|
+
ccz = [zi for zi in z if zi.imag > im_tol]
|
|
214
|
+
sz = [zi for zi in z if abs(zi.imag) <= im_tol]
|
|
215
|
+
|
|
216
|
+
# --- Pass 1: complex-conjugate zero pairs, cancelled first so that any
|
|
217
|
+
# real-pole "downgrade" fallback feeds cleanly into pass 2. ---
|
|
218
|
+
ccz_keep = []
|
|
219
|
+
for zm in ccz:
|
|
220
|
+
dmin, imin = _closest(zm, pr)
|
|
221
|
+
if dmin is not None and dmin < tol * (1.0 + abs(zm)):
|
|
222
|
+
pm = pr[imin]
|
|
223
|
+
if abs(pm.imag) > im_tol:
|
|
224
|
+
# pm is complex: cancel (zm,pm) AND their conjugates together.
|
|
225
|
+
pr.pop(imin)
|
|
226
|
+
_pop_closest_to(np.conj(pm), pr)
|
|
227
|
+
else:
|
|
228
|
+
# pm is real: only half the pair can cancel against it —
|
|
229
|
+
# downgrade the pair to one new real zero, re-queued below.
|
|
230
|
+
sz.append((pm + 2.0 * zm.real) / 3.0)
|
|
231
|
+
pr.pop(imin)
|
|
232
|
+
else:
|
|
233
|
+
ccz_keep.append(zm)
|
|
234
|
+
ccz = ccz_keep
|
|
235
|
+
|
|
236
|
+
# --- Pass 2: simple (real) zeros. ---
|
|
237
|
+
sz_keep = []
|
|
238
|
+
for zm in sz:
|
|
239
|
+
dmin, imin = _closest(zm, pr)
|
|
240
|
+
if dmin is not None and dmin < tol * (1.0 + abs(zm)):
|
|
241
|
+
pm = pr[imin]
|
|
242
|
+
if abs(pm.imag) > im_tol:
|
|
243
|
+
# pm is complex: cancelling only its conjugate would break
|
|
244
|
+
# symmetry — replace the conjugate partner's position instead
|
|
245
|
+
# of deleting it, absorbing the residual mismatch there.
|
|
246
|
+
pr.pop(imin)
|
|
247
|
+
d = [abs(np.conj(pm) - s) for s in pr]
|
|
248
|
+
if d:
|
|
249
|
+
icjg = int(np.argmin(d))
|
|
250
|
+
pr[icjg] = (zm + 2.0 * pm.real) / 3.0
|
|
251
|
+
else:
|
|
252
|
+
pr.pop(imin)
|
|
253
|
+
else:
|
|
254
|
+
sz_keep.append(zm)
|
|
255
|
+
sz = sz_keep
|
|
256
|
+
|
|
257
|
+
zr = []
|
|
258
|
+
for zc in ccz:
|
|
259
|
+
zr.append(zc)
|
|
260
|
+
zr.append(np.conj(zc))
|
|
261
|
+
zr.extend(sz)
|
|
262
|
+
|
|
263
|
+
return np.array(zr, dtype=complex), np.array(pr, dtype=complex)
|
|
264
|
+
|
|
265
|
+
@staticmethod
|
|
266
|
+
def tf(num, den, tol=0.01):
|
|
267
|
+
"""
|
|
268
|
+
Cancel common roots in (num, den) — minreal for raw numpy coefficient
|
|
269
|
+
arrays representing a transfer function's numerator/denominator.
|
|
270
|
+
|
|
271
|
+
Uses `_reducezp` (port of MATLAB `@zpk/minreal.m`'s `REDUCEZP`) rather
|
|
272
|
+
than naive first-match root deletion.
|
|
273
|
+
"""
|
|
274
|
+
# Strip negligible leading coefficients (MATLAB striplz): the gain is
|
|
275
|
+
# taken from num[0]/den[0] below, so a padded leading 0.0 (e.g. from
|
|
276
|
+
# np.polyadd aligning different-degree operands) would otherwise zero
|
|
277
|
+
# the entire numerator (e.g. E from _ztrm_self_adjoint coming back
|
|
278
|
+
# identically 0 for a 2-output P11, gutting every AHinf design that
|
|
279
|
+
# reached _polhinf).
|
|
280
|
+
num = np.atleast_1d(np.asarray(num, float))
|
|
281
|
+
den = np.atleast_1d(np.asarray(den, float))
|
|
282
|
+
_nmax = np.max(np.abs(num)) if num.size else 0.0
|
|
283
|
+
_dmax = np.max(np.abs(den)) if den.size else 0.0
|
|
284
|
+
if _nmax == 0.0:
|
|
285
|
+
return np.array([0.0]), np.array([1.0])
|
|
286
|
+
while len(num) > 1 and abs(num[0]) <= 1e-12 * _nmax:
|
|
287
|
+
num = num[1:]
|
|
288
|
+
while len(den) > 1 and abs(den[0]) <= 1e-12 * _dmax:
|
|
289
|
+
den = den[1:]
|
|
290
|
+
rn = np.roots(num) if len(num) > 1 else np.array([], dtype=complex)
|
|
291
|
+
rd = np.roots(den) if len(den) > 1 else np.array([], dtype=complex)
|
|
292
|
+
zr, pr = Minreal._reducezp(rn, rd, tol)
|
|
293
|
+
sn = float(np.real(num[0])); sd = float(np.real(den[0]))
|
|
294
|
+
nn = np.real(np.poly(zr)).astype(float) * sn if len(zr) > 0 else np.array([sn])
|
|
295
|
+
nd = np.real(np.poly(pr)).astype(float) * sd if len(pr) > 0 else np.array([sd])
|
|
296
|
+
sc = nd[0] if abs(nd[0]) > 1e-30 else 1.0
|
|
297
|
+
return nn / sc, nd / sc
|
|
298
|
+
|
|
299
|
+
@staticmethod
|
|
300
|
+
def _remove(r, x, tol=1e-8):
|
|
301
|
+
"""
|
|
302
|
+
Remove, from `r`, the nearest-matching entry to each element of `x`
|
|
303
|
+
in turn. Port of MATLAB `@zpk/private/remove.m`.
|
|
304
|
+
|
|
305
|
+
When the target `x[i]` is real but its nearest match in `r` is
|
|
306
|
+
complex, every entry of `r` is snapped to its real part before the
|
|
307
|
+
removal — MATLAB's own fix-up (`r(cc) = real(r(cc))` over a full
|
|
308
|
+
index permutation is equivalent to `r = real(r)`), presumably valid
|
|
309
|
+
because by this point in `minreals`' cancellation loop `r` is
|
|
310
|
+
expected to already be real-symmetric and any remaining imaginary
|
|
311
|
+
part is numerical noise to be cleaned up, not genuine structure.
|
|
312
|
+
"""
|
|
313
|
+
r = list(np.asarray(r, dtype=complex))
|
|
314
|
+
for xi in x:
|
|
315
|
+
if len(r) < 1:
|
|
316
|
+
raise ValueError("No more elements to remove")
|
|
317
|
+
d = [abs(ri - xi) for ri in r]
|
|
318
|
+
k = int(np.argmin(d))
|
|
319
|
+
if d[k] > tol:
|
|
320
|
+
raise ValueError(f"Unable to find a term to remove: err={d[k]:g}")
|
|
321
|
+
if np.imag(xi) == 0 and np.imag(r[k]) != 0:
|
|
322
|
+
r = [np.real(ri) for ri in r]
|
|
323
|
+
r.pop(k)
|
|
324
|
+
return np.array(r, dtype=complex)
|
|
325
|
+
|
|
326
|
+
@staticmethod
|
|
327
|
+
def tf_symmetric(num, den, ftype='d', tol=None):
|
|
328
|
+
"""
|
|
329
|
+
Minimal realization of a *symmetric* rational function (a spectral
|
|
330
|
+
density / para-Hermitian quasipolynomial ratio), preserving
|
|
331
|
+
reciprocal-pair symmetry (`z`, `1/z` for `ftype='d'`/`'z'`) or
|
|
332
|
+
negative-pair symmetry (`s`, `-s` for `ftype='s'`) under cancellation.
|
|
333
|
+
|
|
334
|
+
Port of MATLAB `@zpk/minreals.m`. Distinct from `Minreal.tf`
|
|
335
|
+
(`@zpk/minreal.m`'s `REDUCEZP`, which only preserves real-coefficient
|
|
336
|
+
conjugate-pair symmetry): here zeros and poles must *each*
|
|
337
|
+
independently already be symmetric (raises if not — "the function is
|
|
338
|
+
not symmetric"), and cancellation removes a pole/zero pair *together
|
|
339
|
+
with each side's own symmetric partner*, so the result stays
|
|
340
|
+
symmetric too.
|
|
341
|
+
|
|
342
|
+
Used by MATLAB's `zterm.m` (`Z = E - B*B~/A` construction),
|
|
343
|
+
`sdh2coef.m` (`A = A0*A1*A1'`-type products), `sdhinferr.m`,
|
|
344
|
+
`quaderr.m`, `abe2std.m` — all quadratic-form / spectral-density
|
|
345
|
+
constructions in the H2/H∞ polynomial design pipeline.
|
|
346
|
+
|
|
347
|
+
Parameters
|
|
348
|
+
----------
|
|
349
|
+
num, den : ndarray
|
|
350
|
+
Numerator/denominator coefficients (descending powers).
|
|
351
|
+
ftype : {'d', 's'}
|
|
352
|
+
'd' (default) for discrete-time (reciprocal pairs); 's' for
|
|
353
|
+
continuous-time (negative pairs).
|
|
354
|
+
tol : float, optional
|
|
355
|
+
Relative tolerance; default `sqrt(eps)`.
|
|
356
|
+
|
|
357
|
+
Returns
|
|
358
|
+
-------
|
|
359
|
+
num_min, den_min : ndarray
|
|
360
|
+
"""
|
|
361
|
+
from directsd.polynomial.poln import _extrpair
|
|
362
|
+
|
|
363
|
+
if tol is None:
|
|
364
|
+
tol = np.sqrt(np.finfo(float).eps)
|
|
365
|
+
|
|
366
|
+
zz = np.roots(num) if len(num) > 1 else np.array([], dtype=complex)
|
|
367
|
+
pp = np.roots(den) if len(den) > 1 else np.array([], dtype=complex)
|
|
368
|
+
sn = float(np.real(num[0])) if len(num) > 0 else 1.0
|
|
369
|
+
sd = float(np.real(den[0])) if len(den) > 0 else 1.0
|
|
370
|
+
|
|
371
|
+
zs, z_rem, z0 = _extrpair(zz, ftype, tol)
|
|
372
|
+
ps, p_rem, p0 = _extrpair(pp, ftype, tol)
|
|
373
|
+
if z_rem.size > 0 or p_rem.size > 0:
|
|
374
|
+
raise ValueError("The function is not symmetric")
|
|
375
|
+
|
|
376
|
+
c0 = min(z0, p0)
|
|
377
|
+
z0 -= c0
|
|
378
|
+
p0 -= c0
|
|
379
|
+
|
|
380
|
+
if ftype == 's':
|
|
381
|
+
if z0 % 2 != 0 or p0 % 2 != 0:
|
|
382
|
+
raise ValueError("Incorrect zeros or poles at the origin")
|
|
383
|
+
else:
|
|
384
|
+
if len(zs) + z0 != len(ps) + p0:
|
|
385
|
+
raise ValueError("Incorrect zeros or poles at the origin")
|
|
386
|
+
|
|
387
|
+
zs_l, ps_l = list(zs), list(ps)
|
|
388
|
+
modified = False
|
|
389
|
+
i = 0
|
|
390
|
+
while i < len(ps_l):
|
|
391
|
+
A = ps_l[i]
|
|
392
|
+
tolA = max(tol * abs(A), tol)
|
|
393
|
+
if len(zs_l) < 1:
|
|
394
|
+
break
|
|
395
|
+
d = [abs(z - A) for z in zs_l]
|
|
396
|
+
if min(d) < tolA:
|
|
397
|
+
modified = True
|
|
398
|
+
targets = (
|
|
399
|
+
[A, np.conj(A)]
|
|
400
|
+
if abs(np.imag(A)) > np.finfo(float).eps
|
|
401
|
+
else [A]
|
|
402
|
+
)
|
|
403
|
+
zs_l = list(Minreal._remove(zs_l, targets, 100 * tolA))
|
|
404
|
+
ps_l = list(Minreal._remove(ps_l, targets, 100 * tolA))
|
|
405
|
+
else:
|
|
406
|
+
i += 1
|
|
407
|
+
|
|
408
|
+
if not modified:
|
|
409
|
+
return np.asarray(num, dtype=float), np.asarray(den, dtype=float)
|
|
410
|
+
|
|
411
|
+
zs_arr, ps_arr = np.array(zs_l, dtype=complex), np.array(ps_l, dtype=complex)
|
|
412
|
+
if ftype == 's':
|
|
413
|
+
zz_new = np.concatenate([zs_arr, -zs_arr, np.zeros(z0 // 2, dtype=complex)])
|
|
414
|
+
pp_new = np.concatenate([ps_arr, -ps_arr, np.zeros(p0 // 2, dtype=complex)])
|
|
415
|
+
else:
|
|
416
|
+
z_recip = 1.0 / zs_arr if zs_arr.size > 0 else zs_arr
|
|
417
|
+
p_recip = 1.0 / ps_arr if ps_arr.size > 0 else ps_arr
|
|
418
|
+
zz_new = np.concatenate([zs_arr, z_recip, np.zeros(z0, dtype=complex)])
|
|
419
|
+
pp_new = np.concatenate([ps_arr, p_recip, np.zeros(p0, dtype=complex)])
|
|
420
|
+
|
|
421
|
+
num_min = (np.real(np.poly(zz_new)).astype(float) * sn
|
|
422
|
+
if zz_new.size > 0 else np.array([sn]))
|
|
423
|
+
den_min = (np.real(np.poly(pp_new)).astype(float) * sd
|
|
424
|
+
if pp_new.size > 0 else np.array([sd]))
|
|
425
|
+
return num_min, den_min
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Riccati-equation solvers robust to singular weights and unit-circle
|
|
3
|
+
eigenvalues.
|
|
4
|
+
|
|
5
|
+
Port of MATLAB ``dsdlinalg/dare1.m`` (K. Polyakov) — the extended-pencil
|
|
6
|
+
(Arnold & Laub 1984) DARE solver used by ``dsdsspace/h2reg.m``'s discrete
|
|
7
|
+
Chen-Francis branch. Unlike ``scipy.linalg.solve_discrete_are`` it
|
|
8
|
+
|
|
9
|
+
* accepts a SINGULAR control weight ``R`` (the pencil is compressed by a QR
|
|
10
|
+
step instead of inverting R) — sampled-data lifted H2/L2 problems have
|
|
11
|
+
``R = D12'D12`` genuinely tiny/singular and ``D21 = 0``;
|
|
12
|
+
* handles pencil eigenvalues ON the unit circle (marginal integrator modes)
|
|
13
|
+
by splitting each near-1 eigenvalue into a (1-eps, 1+eps) pair distributed
|
|
14
|
+
between the stable/antistable deflating subspaces ("Polyakov 2005"), with
|
|
15
|
+
a conditioning-driven retry over the split assignment (the permUcPoles
|
|
16
|
+
search in dare1.m).
|
|
17
|
+
|
|
18
|
+
References
|
|
19
|
+
----------
|
|
20
|
+
[1] W.F. Arnold, A.J. Laub, "Generalized Eigenproblem Algorithms and
|
|
21
|
+
Software for Algebraic Riccati Equations", Proc. IEEE 72 (1984).
|
|
22
|
+
[2] K. Polyakov, "Separation of eigenvalues on the unit circle", 2005.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import numpy as np
|
|
28
|
+
import scipy.linalg as la
|
|
29
|
+
|
|
30
|
+
__all__ = ['dare1']
|
|
31
|
+
|
|
32
|
+
_SQRT_EPS = np.sqrt(np.finfo(float).eps)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _select_patterns(n_uc):
|
|
36
|
+
"""Assignment patterns for the near-unit eigenvalues (True = antistable
|
|
37
|
+
group). MATLAB's default is alternating starting 'inside' (dir=-1 →
|
|
38
|
+
first gets 1-eps); permUcPoles then searches other distributions when
|
|
39
|
+
X1 is ill-conditioned. We enumerate the practical patterns."""
|
|
40
|
+
base = [False, True] * ((n_uc + 1) // 2)
|
|
41
|
+
alt1 = base[:n_uc] # inside, outside, inside, ...
|
|
42
|
+
alt2 = [not v for v in alt1] # outside, inside, ...
|
|
43
|
+
yield alt1
|
|
44
|
+
if n_uc > 0:
|
|
45
|
+
yield alt2
|
|
46
|
+
yield [False] * n_uc
|
|
47
|
+
yield [True] * n_uc
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def dare1(A, B, Q, R, S=None, E=None, uc_tol=None):
|
|
51
|
+
"""
|
|
52
|
+
Solve the discrete algebraic Riccati equation
|
|
53
|
+
|
|
54
|
+
A'XA - E'XE - (A'XB + S)(R + B'XB)^{-1}(B'XA + S') + Q = 0
|
|
55
|
+
|
|
56
|
+
via the extended matrix pencil + QZ, tolerating singular ``R`` and
|
|
57
|
+
unit-circle pencil eigenvalues. Port of ``dare1.m``.
|
|
58
|
+
|
|
59
|
+
Returns
|
|
60
|
+
-------
|
|
61
|
+
X : (n, n) ndarray
|
|
62
|
+
Stabilizing (symmetric) solution.
|
|
63
|
+
poles : ndarray
|
|
64
|
+
Closed-loop pole set (the stable half of the pencil spectrum).
|
|
65
|
+
err : float
|
|
66
|
+
Relative residual of the Riccati equation (0 when R + B'XB is
|
|
67
|
+
numerically singular and the residual cannot be formed — matching
|
|
68
|
+
dare1.m).
|
|
69
|
+
|
|
70
|
+
Raises
|
|
71
|
+
------
|
|
72
|
+
numpy.linalg.LinAlgError
|
|
73
|
+
If no eigenvalue split yields an invertible X1 (dare1.m returns
|
|
74
|
+
X = inf / err = -1 in that case).
|
|
75
|
+
"""
|
|
76
|
+
A = np.atleast_2d(np.asarray(A, float))
|
|
77
|
+
B = np.atleast_2d(np.asarray(B, float))
|
|
78
|
+
Q = np.atleast_2d(np.asarray(Q, float))
|
|
79
|
+
R = np.atleast_2d(np.asarray(R, float))
|
|
80
|
+
n = A.shape[0]
|
|
81
|
+
m = B.shape[1]
|
|
82
|
+
if S is None:
|
|
83
|
+
S = np.zeros((n, m))
|
|
84
|
+
S = np.atleast_2d(np.asarray(S, float))
|
|
85
|
+
if E is None:
|
|
86
|
+
E = np.eye(n)
|
|
87
|
+
E = np.atleast_2d(np.asarray(E, float))
|
|
88
|
+
n2 = 2 * n
|
|
89
|
+
|
|
90
|
+
if uc_tol is None:
|
|
91
|
+
uc_tol = _SQRT_EPS
|
|
92
|
+
|
|
93
|
+
# ── Scaling of the weights (dare1.m) ─────────────────────────────────
|
|
94
|
+
scale = (np.linalg.norm(Q, 1) + np.linalg.norm(R, 1)
|
|
95
|
+
+ np.linalg.norm(S, 1))
|
|
96
|
+
if scale < 1e-300:
|
|
97
|
+
raise np.linalg.LinAlgError("dare1: Q, R, S are all zero")
|
|
98
|
+
QQ, RR, SS = Q / scale, R / scale, S / scale
|
|
99
|
+
|
|
100
|
+
# ── Extended pencil (2n+m)×(2n+m): a·x = λ·b·x ─────────────────────
|
|
101
|
+
a_ext = np.block([
|
|
102
|
+
[E, np.zeros((n, n)), np.zeros((n, m))],
|
|
103
|
+
[np.zeros((n, n)), A.T, np.zeros((n, m))],
|
|
104
|
+
[np.zeros((m, n)), -B.T, np.zeros((m, m))],
|
|
105
|
+
])
|
|
106
|
+
b_ext = np.block([
|
|
107
|
+
[A, np.zeros((n, n)), B],
|
|
108
|
+
[-QQ, E.T, -SS],
|
|
109
|
+
[SS.T, np.zeros((m, n)), RR],
|
|
110
|
+
])
|
|
111
|
+
|
|
112
|
+
# ── Compression step (works even when R is singular): project onto the
|
|
113
|
+
# orthogonal complement of b's last m columns ────────────────────────
|
|
114
|
+
q_full, _ = np.linalg.qr(b_ext[:, n2:n2 + m], mode='complete')
|
|
115
|
+
# MATLAB: q(:, 2n+m:-1:m+1)' — the complement columns (reversed order;
|
|
116
|
+
# the reversal does not change the span but is kept for parity).
|
|
117
|
+
qc = q_full[:, m:n2 + m][:, ::-1]
|
|
118
|
+
a_c = qc.T @ a_ext[:, :n2]
|
|
119
|
+
b_c = qc.T @ b_ext[:, :n2]
|
|
120
|
+
|
|
121
|
+
# ── Eigenvalues of the compressed pencil (plain QZ first, to identify
|
|
122
|
+
# the near-unit-circle ones deterministically) ───────────────────────
|
|
123
|
+
aa0, bb0, alpha0, beta0, _, _ = la.ordqz(a_c, b_c, sort=lambda a_, b_:
|
|
124
|
+
np.ones_like(np.atleast_1d(a_), dtype=bool),
|
|
125
|
+
output='complex')
|
|
126
|
+
with np.errstate(divide='ignore', invalid='ignore'):
|
|
127
|
+
lam0 = np.where(np.abs(beta0) < 1e-10, np.inf, alpha0 / beta0)
|
|
128
|
+
n_uc = int(np.sum(np.abs(lam0 - 1.0) < uc_tol))
|
|
129
|
+
|
|
130
|
+
best = None # (condX1, X1, X2, poles)
|
|
131
|
+
for pattern in _select_patterns(n_uc):
|
|
132
|
+
# Stateful selection: True → antistable (top-left) group.
|
|
133
|
+
state = {'k': 0}
|
|
134
|
+
|
|
135
|
+
def _sel(alpha, beta):
|
|
136
|
+
alpha = np.atleast_1d(alpha)
|
|
137
|
+
beta = np.atleast_1d(beta)
|
|
138
|
+
out = np.empty(alpha.shape, dtype=bool)
|
|
139
|
+
for i in range(len(alpha)):
|
|
140
|
+
if abs(beta[i]) < 1e-10:
|
|
141
|
+
out[i] = True # λ = ∞ → antistable
|
|
142
|
+
continue
|
|
143
|
+
lam = alpha[i] / beta[i]
|
|
144
|
+
if abs(lam - 1.0) < uc_tol:
|
|
145
|
+
j = state['k']
|
|
146
|
+
out[i] = pattern[j] if j < len(pattern) else True
|
|
147
|
+
state['k'] = j + 1
|
|
148
|
+
else:
|
|
149
|
+
out[i] = bool(abs(lam) > 1.0)
|
|
150
|
+
return out
|
|
151
|
+
|
|
152
|
+
try:
|
|
153
|
+
aa, bb, alpha, beta, q_z, z = la.ordqz(a_c, b_c, sort=_sel,
|
|
154
|
+
output='complex')
|
|
155
|
+
except Exception:
|
|
156
|
+
continue
|
|
157
|
+
|
|
158
|
+
X1 = z[:n, :n]
|
|
159
|
+
X2 = z[n:n2, :n]
|
|
160
|
+
try:
|
|
161
|
+
cond_X1 = np.linalg.cond(X1)
|
|
162
|
+
except Exception:
|
|
163
|
+
continue
|
|
164
|
+
if not np.isfinite(cond_X1):
|
|
165
|
+
continue
|
|
166
|
+
|
|
167
|
+
with np.errstate(divide='ignore', invalid='ignore'):
|
|
168
|
+
lam_all = np.where(np.abs(beta) < 1e-10, np.inf, alpha / beta)
|
|
169
|
+
poles = lam_all[n:]
|
|
170
|
+
|
|
171
|
+
if best is None or cond_X1 < best[0]:
|
|
172
|
+
best = (cond_X1, X1, X2, poles)
|
|
173
|
+
if cond_X1 < 1e12:
|
|
174
|
+
break
|
|
175
|
+
|
|
176
|
+
if best is None:
|
|
177
|
+
raise np.linalg.LinAlgError("dare1: QZ ordering failed")
|
|
178
|
+
cond_X1, X1, X2, poles = best
|
|
179
|
+
|
|
180
|
+
# ── X = X2 / X1 (dare1.m: LU with rcond check) ───────────────────────
|
|
181
|
+
if cond_X1 > 1.0 / np.finfo(float).eps:
|
|
182
|
+
raise np.linalg.LinAlgError(
|
|
183
|
+
f"dare1: X1 is numerically singular (cond={cond_X1:.3g}); "
|
|
184
|
+
f"no stabilizing solution found")
|
|
185
|
+
X = np.real(X2 @ np.linalg.inv(X1))
|
|
186
|
+
X = scale * (X + X.T) / 2.0
|
|
187
|
+
|
|
188
|
+
# ── Residual (dare1.m: err = 0 when R + B'XB is singular) ────────────
|
|
189
|
+
RBX = R + B.T @ X @ B
|
|
190
|
+
err = 0.0
|
|
191
|
+
if np.linalg.cond(RBX) < 1.0 / np.finfo(float).eps:
|
|
192
|
+
res = (A.T @ X @ A - E.T @ X @ E
|
|
193
|
+
- (A.T @ X @ B + S) @ np.linalg.solve(RBX, (B.T @ X @ A + S.T))
|
|
194
|
+
+ Q)
|
|
195
|
+
err = float(np.linalg.norm(res) / max(1.0, np.linalg.norm(X)))
|
|
196
|
+
|
|
197
|
+
return X, poles, err
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
from directsd.polynomial.poln import Poln, s_var, z_var, q_var, p_var, d_var
|
|
2
|
+
from directsd.polynomial.operations import (
|
|
3
|
+
compat, deg, gcd, coprime, triple, factor,
|
|
4
|
+
striplz, recip, vec,
|
|
5
|
+
)
|
|
6
|
+
from directsd.polynomial.diophantine import dioph, dioph2, diophsys, diophsys2
|
|
7
|
+
from directsd.polynomial.spectral import sfactor, sfactfft
|
|
8
|
+
from directsd.polynomial.transforms import ztrm, dtfm
|
|
9
|
+
from directsd.polynomial.utils import (
|
|
10
|
+
bilintr, improper, tf2nd, separtf, sumzpk, bilinss,
|
|
11
|
+
)
|